Skip to content

Commit 3913876

Browse files
fix(mcp): keep empty list envelopes and array structured content (#1239)
An empty collection is still a list result. Projecting it as a single entity wiped pagination and left the model with an empty object.
1 parent c19f43c commit 3913876

3 files changed

Lines changed: 92 additions & 12 deletions

File tree

‎src/mcp/plugin.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,15 @@ function sanitizeMcpResultContent(
6666
}
6767

6868
function serializeStructuredContent(
69-
value: Record<string, unknown>,
69+
value: Record<string, unknown> | unknown[],
7070
): { serialized: string; detail?: Record<string, unknown> } | undefined {
7171
try {
7272
const serialized = JSON.stringify(value);
7373
return {
7474
serialized,
75-
...(serialized.length <= MAX_RESULT_CHARS ? { detail: value } : {}),
75+
...(serialized.length <= MAX_RESULT_CHARS && !Array.isArray(value)
76+
? { detail: value }
77+
: {}),
7678
};
7779
} catch {
7880
return undefined;

‎src/mcp/result-projection.test.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,22 @@ describe("projectMcpJsonValue", () => {
9292
});
9393
});
9494

95+
test("empty list envelope keeps the collection and pagination siblings", () => {
96+
const projected = projectMcpJsonValue(
97+
{
98+
issues: [],
99+
hasNextPage: false,
100+
cursor: null,
101+
},
102+
MCP_DEFAULT_ENTITY_FIELDS,
103+
);
104+
expect(projected).toEqual({
105+
issues: [],
106+
hasNextPage: false,
107+
cursor: null,
108+
});
109+
});
110+
95111
test("explicit fields expand to those keys including description", () => {
96112
const projected = projectMcpJsonValue(
97113
ISSUE,
@@ -232,6 +248,48 @@ describe("applyMcpResultProjection", () => {
232248
expect(JSON.parse(result.blocks[0]?.text ?? "")).toEqual(payload);
233249
});
234250

251+
test("empty list_issues envelope is not wiped to an empty object", () => {
252+
const payload = { issues: [], hasNextPage: false, cursor: null };
253+
const result = applyMcpResultProjection({
254+
serverName: "linear",
255+
toolName: "list_issues",
256+
args: {},
257+
blocks: [{ type: "text", text: JSON.stringify(payload) }],
258+
structuredContent: payload,
259+
});
260+
expect(JSON.parse(result.blocks[0]?.text ?? "")).toEqual(payload);
261+
expect(result.structuredContent).toEqual(payload);
262+
});
263+
264+
test("list_comments keeps a projected structuredContent array", () => {
265+
const comments = [
266+
{
267+
id: "comment-1",
268+
url: "https://linear.app/comment-1",
269+
body: "done",
270+
extra: "drop",
271+
},
272+
{
273+
id: "comment-2",
274+
url: "https://linear.app/comment-2",
275+
body: "also done",
276+
},
277+
];
278+
const result = applyMcpResultProjection({
279+
serverName: "linear",
280+
toolName: "list_comments",
281+
args: {},
282+
blocks: [{ type: "text", text: JSON.stringify(comments) }],
283+
structuredContent: comments,
284+
});
285+
const projected = [
286+
{ id: "comment-1", url: "https://linear.app/comment-1" },
287+
{ id: "comment-2", url: "https://linear.app/comment-2" },
288+
];
289+
expect(JSON.parse(result.blocks[0]?.text ?? "")).toEqual(projected);
290+
expect(result.structuredContent).toEqual(projected);
291+
});
292+
235293
test("get_issue is not projected", () => {
236294
const result = applyMcpResultProjection({
237295
serverName: "linear",

‎src/mcp/result-projection.ts‎

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,12 @@ export interface McpProjectionInput {
3030
toolName: string;
3131
args: Record<string, unknown>;
3232
blocks: MCPContentBlock[];
33-
structuredContent?: Record<string, unknown>;
33+
structuredContent?: Record<string, unknown> | unknown[];
3434
}
3535

3636
export interface McpProjectionOutput {
3737
blocks: MCPContentBlock[];
38-
structuredContent?: Record<string, unknown>;
38+
structuredContent?: Record<string, unknown> | unknown[];
3939
}
4040

4141
export function applyMcpResultProjection(
@@ -62,7 +62,7 @@ export function applyMcpResultProjection(
6262
const structuredContent =
6363
input.structuredContent === undefined
6464
? undefined
65-
: asRecord(
65+
: asProjectedStructured(
6666
projectMcpJsonValue(
6767
input.structuredContent,
6868
fields.keys,
@@ -91,13 +91,10 @@ export function projectMcpJsonValue(
9191
}
9292
if (!isRecord(value)) return value;
9393

94-
const recordArrays = Object.entries(value).filter(
95-
([, item]) => Array.isArray(item) && item.some((entry) => isRecord(entry)),
96-
);
97-
if (recordArrays.length > 0) {
94+
if (isListEnvelope(value)) {
9895
const out = Object.create(null) as Record<string, unknown>;
9996
for (const [key, item] of Object.entries(value)) {
100-
if (Array.isArray(item) && item.some((entry) => isRecord(entry))) {
97+
if (isProjectedList(item)) {
10198
out[key] = item.map((entry) =>
10299
isRecord(entry)
103100
? pickEntityFields(entry, fields, shortenNestedRefs, applyAliases)
@@ -236,8 +233,31 @@ function copyField(value: unknown, shortenNestedRefs: boolean): unknown {
236233
return Object.keys(nested).length > 0 ? nested : undefined;
237234
}
238235

239-
function asRecord(value: unknown): Record<string, unknown> | undefined {
240-
return isRecord(value) ? value : undefined;
236+
function asProjectedStructured(
237+
value: unknown,
238+
): Record<string, unknown> | unknown[] | undefined {
239+
if (isRecord(value) || Array.isArray(value)) return value;
240+
return undefined;
241+
}
242+
243+
function isListEnvelope(value: Record<string, unknown>): boolean {
244+
let sawRecordArray = false;
245+
let sawEmptyArray = false;
246+
for (const item of Object.values(value)) {
247+
if (!Array.isArray(item)) continue;
248+
if (item.some((entry) => isRecord(entry))) sawRecordArray = true;
249+
else if (item.length === 0) sawEmptyArray = true;
250+
}
251+
if (sawRecordArray) return true;
252+
if (!sawEmptyArray) return false;
253+
return !Object.hasOwn(value, "id") && !Object.hasOwn(value, "identifier");
254+
}
255+
256+
function isProjectedList(value: unknown): value is unknown[] {
257+
return (
258+
Array.isArray(value) &&
259+
(value.length === 0 || value.some((entry) => isRecord(entry)))
260+
);
241261
}
242262

243263
function isRecord(value: unknown): value is Record<string, unknown> {

0 commit comments

Comments
 (0)