diff --git a/src/mcp/plugin.ts b/src/mcp/plugin.ts index 897a8d9ca..f91f14bfb 100644 --- a/src/mcp/plugin.ts +++ b/src/mcp/plugin.ts @@ -66,13 +66,15 @@ function sanitizeMcpResultContent( } function serializeStructuredContent( - value: Record, + value: Record | unknown[], ): { serialized: string; detail?: Record } | undefined { try { const serialized = JSON.stringify(value); return { serialized, - ...(serialized.length <= MAX_RESULT_CHARS ? { detail: value } : {}), + ...(serialized.length <= MAX_RESULT_CHARS && !Array.isArray(value) + ? { detail: value } + : {}), }; } catch { return undefined; diff --git a/src/mcp/result-projection.test.ts b/src/mcp/result-projection.test.ts index 0fb4fcda6..621c835dd 100644 --- a/src/mcp/result-projection.test.ts +++ b/src/mcp/result-projection.test.ts @@ -92,6 +92,22 @@ describe("projectMcpJsonValue", () => { }); }); + test("empty list envelope keeps the collection and pagination siblings", () => { + const projected = projectMcpJsonValue( + { + issues: [], + hasNextPage: false, + cursor: null, + }, + MCP_DEFAULT_ENTITY_FIELDS, + ); + expect(projected).toEqual({ + issues: [], + hasNextPage: false, + cursor: null, + }); + }); + test("explicit fields expand to those keys including description", () => { const projected = projectMcpJsonValue( ISSUE, @@ -232,6 +248,48 @@ describe("applyMcpResultProjection", () => { expect(JSON.parse(result.blocks[0]?.text ?? "")).toEqual(payload); }); + test("empty list_issues envelope is not wiped to an empty object", () => { + const payload = { issues: [], hasNextPage: false, cursor: null }; + const result = applyMcpResultProjection({ + serverName: "linear", + toolName: "list_issues", + args: {}, + blocks: [{ type: "text", text: JSON.stringify(payload) }], + structuredContent: payload, + }); + expect(JSON.parse(result.blocks[0]?.text ?? "")).toEqual(payload); + expect(result.structuredContent).toEqual(payload); + }); + + test("list_comments keeps a projected structuredContent array", () => { + const comments = [ + { + id: "comment-1", + url: "https://linear.app/comment-1", + body: "done", + extra: "drop", + }, + { + id: "comment-2", + url: "https://linear.app/comment-2", + body: "also done", + }, + ]; + const result = applyMcpResultProjection({ + serverName: "linear", + toolName: "list_comments", + args: {}, + blocks: [{ type: "text", text: JSON.stringify(comments) }], + structuredContent: comments, + }); + const projected = [ + { id: "comment-1", url: "https://linear.app/comment-1" }, + { id: "comment-2", url: "https://linear.app/comment-2" }, + ]; + expect(JSON.parse(result.blocks[0]?.text ?? "")).toEqual(projected); + expect(result.structuredContent).toEqual(projected); + }); + test("get_issue is not projected", () => { const result = applyMcpResultProjection({ serverName: "linear", diff --git a/src/mcp/result-projection.ts b/src/mcp/result-projection.ts index f8fe7ddd1..83a60e09c 100644 --- a/src/mcp/result-projection.ts +++ b/src/mcp/result-projection.ts @@ -30,12 +30,12 @@ export interface McpProjectionInput { toolName: string; args: Record; blocks: MCPContentBlock[]; - structuredContent?: Record; + structuredContent?: Record | unknown[]; } export interface McpProjectionOutput { blocks: MCPContentBlock[]; - structuredContent?: Record; + structuredContent?: Record | unknown[]; } export function applyMcpResultProjection( @@ -62,7 +62,7 @@ export function applyMcpResultProjection( const structuredContent = input.structuredContent === undefined ? undefined - : asRecord( + : asProjectedStructured( projectMcpJsonValue( input.structuredContent, fields.keys, @@ -91,13 +91,10 @@ export function projectMcpJsonValue( } if (!isRecord(value)) return value; - const recordArrays = Object.entries(value).filter( - ([, item]) => Array.isArray(item) && item.some((entry) => isRecord(entry)), - ); - if (recordArrays.length > 0) { + if (isListEnvelope(value)) { const out = Object.create(null) as Record; for (const [key, item] of Object.entries(value)) { - if (Array.isArray(item) && item.some((entry) => isRecord(entry))) { + if (isProjectedList(item)) { out[key] = item.map((entry) => isRecord(entry) ? pickEntityFields(entry, fields, shortenNestedRefs, applyAliases) @@ -236,8 +233,31 @@ function copyField(value: unknown, shortenNestedRefs: boolean): unknown { return Object.keys(nested).length > 0 ? nested : undefined; } -function asRecord(value: unknown): Record | undefined { - return isRecord(value) ? value : undefined; +function asProjectedStructured( + value: unknown, +): Record | unknown[] | undefined { + if (isRecord(value) || Array.isArray(value)) return value; + return undefined; +} + +function isListEnvelope(value: Record): boolean { + let sawRecordArray = false; + let sawEmptyArray = false; + for (const item of Object.values(value)) { + if (!Array.isArray(item)) continue; + if (item.some((entry) => isRecord(entry))) sawRecordArray = true; + else if (item.length === 0) sawEmptyArray = true; + } + if (sawRecordArray) return true; + if (!sawEmptyArray) return false; + return !Object.hasOwn(value, "id") && !Object.hasOwn(value, "identifier"); +} + +function isProjectedList(value: unknown): value is unknown[] { + return ( + Array.isArray(value) && + (value.length === 0 || value.some((entry) => isRecord(entry))) + ); } function isRecord(value: unknown): value is Record {