Skip to content

Commit 8c7a8ab

Browse files
authored
Merge pull request #873 from zereight/fix/structured-content-array-guard
fix: drop non-object structuredContent after a jmespath filter
2 parents 3cbff1c + 4219d07 commit 8c7a8ab

3 files changed

Lines changed: 49 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
3131

3232
##### Fixed
3333

34+
- A `jmespath` filter that turns `structuredContent` into an array, a scalar, or `null` (for example `items[].title`) now drops `structuredContent` instead of returning a value the MCP SDK rejects. The filtered JSON is still in the text content. No tool sets `structuredContent` today ([#873](https://github.com/zereight/gitlab-mcp/pull/873))
3435
- Tool calls that cannot reach GitLab now name the reason (for example `ECONNREFUSED`, `ENOTFOUND`, or a TLS certificate code) and a hint, instead of only `fetch failed` ([#856](https://github.com/zereight/gitlab-mcp/pull/856))
3536
- Values interpolated into GitLab URL paths (project/group ids, file paths, branch and tag names, keys, slugs) are now fully percent-decoded and rejected when they contain a `.`/`..` segment, a leading `/`, an empty path segment, or a control character such as NUL ([#760](https://github.com/zereight/gitlab-mcp/pull/760)). Because values are decoded before encoding, a name that literally contains a percent escape (for example a tag named `release%2F1.0`) now resolves to the decoded name (`release/1.0`), and a value mixing a valid escape with a stray `%` is rejected
3637
- `execute_graphql` in `GITLAB_PERMISSION_MODE=modify` now rejects destructive GraphQL mutations whose top-level field names use teardown verbs (for example `environmentStop`, `pipelineCancel`, and `jobUnschedule`), not only delete-style names ([#755](https://github.com/zereight/gitlab-mcp/pull/755))

‎test/utils/jmespath-tool-result.test.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import assert from "node:assert/strict";
22
import { describe, test } from "node:test";
3+
import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js";
34
import {
45
applyJmespathFilter,
56
applyJmespathToToolResult,
@@ -123,6 +124,42 @@ describe("jmespath tool result helpers", () => {
123124
assert.deepEqual(applyJmespathToToolResult(original, "starts_with(title, `a`)"), original);
124125
});
125126

127+
test("applyJmespathToToolResult keeps structuredContent when the filter returns an object", () => {
128+
const result = applyJmespathToToolResult(
129+
{
130+
content: [{ type: "text", text: JSON.stringify({ items: [{ title: "a" }], total: 1 }) }],
131+
structuredContent: { items: [{ title: "a" }], total: 1 },
132+
},
133+
"{total: total}"
134+
);
135+
assert.deepEqual(result.structuredContent, { total: 1 });
136+
assert.equal(CallToolResultSchema.safeParse(result).success, true);
137+
});
138+
139+
test("applyJmespathToToolResult drops structuredContent the filter turns into a non-object", () => {
140+
const items = [{ title: "a" }, { title: "b" }];
141+
for (const [expression, filtered] of [
142+
["items[].title", ["a", "b"]],
143+
["missing", null],
144+
["length(items)", 2],
145+
] as const) {
146+
const result = applyJmespathToToolResult(
147+
{
148+
content: [{ type: "text", text: JSON.stringify({ items }) }],
149+
structuredContent: { items },
150+
},
151+
expression
152+
);
153+
assert.equal("structuredContent" in result, false, expression);
154+
assert.deepEqual(
155+
JSON.parse((result.content as { text: string }[])[0].text),
156+
filtered,
157+
expression
158+
);
159+
assert.equal(CallToolResultSchema.safeParse(result).success, true, expression);
160+
}
161+
});
162+
126163
test("getJmespathSyntaxError rejects invalid expressions before execution", () => {
127164
assert.match(getJmespathSyntaxError("[[[") ?? "", /Invalid jmespath expression/);
128165
assert.equal(getJmespathSyntaxError("[].title"), undefined);

‎utils/jmespath-tool-result.ts‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ function isContentBlock(value: unknown): value is { type?: unknown; text?: unkno
5656
return Boolean(value && typeof value === "object");
5757
}
5858

59+
function isJsonObject(value: unknown): value is Record<string, unknown> {
60+
return typeof value === "object" && value !== null && !Array.isArray(value);
61+
}
62+
5963
function prepareDataForJmespath(data: unknown, maskValue?: (value: unknown) => unknown): unknown {
6064
return maskValue ? maskValue(data) : data;
6165
}
@@ -102,9 +106,13 @@ export function applyJmespathToToolResult<T extends ToolResultLike>(
102106
return parsed.ok ? { ...block, text: JSON.stringify(filter(parsed.value)) } : block;
103107
})
104108
: result.content;
105-
return result.structuredContent === undefined
106-
? { ...result, content }
107-
: { ...result, content, structuredContent: filter(result.structuredContent) };
109+
if (result.structuredContent === undefined) return { ...result, content };
110+
// MCP structuredContent must be a JSON object, and the SDK rejects the whole tools/call
111+
// result otherwise. "items[].title" or a missing path is not one, so drop it; the text keeps it.
112+
const structuredContent = filter(result.structuredContent);
113+
if (isJsonObject(structuredContent)) return { ...result, content, structuredContent };
114+
const { structuredContent: _dropped, ...rest } = result;
115+
return { ...rest, content } as T;
108116
} catch {
109117
return result;
110118
}

0 commit comments

Comments
 (0)