From 18d65f8e106b1ee02e9d8a9cf482cce00fbc9dbe Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Tue, 29 Sep 2026 07:07:38 -0600 Subject: [PATCH] fix(mcp): return status and stale refusals as text only (#2088) Claude Code shows the model a tool result's structuredContent in place of its content text, under every protocol version. codegraph_status and explore's stale-index refusal both set structuredContent.freshness, so the model saw a JSON object instead of the refusal, its retry guidance or the status report. The text already carries every field, and nothing in the codebase reads structuredContent, so both results drop it. The tests now assert that the field is absent and that the text keeps the refusal line and the stale file list. --- CHANGELOG.md | 1 + __tests__/mcp-stale-refusal.test.ts | 7 +++++-- __tests__/mcp-status-freshness.test.ts | 5 ++--- src/mcp/tools.ts | 10 +++++----- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e02a77eeb..56e1ae2c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - A Swift reference to a type now links to the type's own declaration, not to a file that extends it. An `extension View { … }` or `extension Text { … }` used to stand in for SwiftUI's type, so every view, every `Text("…")` and every `Color.red` in an app linked to whichever file happened to extend it. Those files topped the most-depended-on lists and their impact reached the whole app. A bare name like `@State`, `@Test` or `Result<…>` no longer links to some other type's nested `State` or `Result`, and a qualified name like `Build.Id` links to the `Id` it names. Methods declared in an extension of an SDK type still resolve on the types that conform to it, and a protocol's methods declared in any of its extensions now connect to each conforming type's own implementation. Re-index Swift projects after upgrading. - A Vapor route now links to the handler it names. `use: SearchController.show` used to link to whichever controller's `show` came first, so routes with a common handler name, like `show`, `index` or `get`, pointed at another endpoint's code in callers, impact and `codegraph_explore` answers. Nested types like `API.PackageController.get` and handlers declared in an extension of the controller now resolve too, and `use: self.index` resolves to the collection's own `index`. Re-index Vapor projects after upgrading. - A PHP call written without a receiver, such as `redirect($url)`, `view('books.show')`, `auth()` or `basename($path)`, is a function call, and no longer links to a same-named method, field or class elsewhere in the project. These wrong links showed up in callers, impact and `codegraph_explore` answers wherever a Laravel helper or PHP built-in shared its name with a project member. Re-index PHP projects after upgrading. +- Claude Code now shows the model the full text of `codegraph_status` and of explore's stale-index refusal. Both results also carried `structuredContent`, and Claude Code shows a result's `structuredContent` in place of its text, so the model saw only a freshness JSON object: no refusal message, no retry guidance and no status report. The text already carried the same fields, so both results are now text only. (#2088) ## [1.6.1] - 2026-09-29 diff --git a/__tests__/mcp-stale-refusal.test.ts b/__tests__/mcp-stale-refusal.test.ts index 53362c7a2..8659e8140 100644 --- a/__tests__/mcp-stale-refusal.test.ts +++ b/__tests__/mcp-stale-refusal.test.ts @@ -121,7 +121,9 @@ describe('a degraded index refuses answers from changed files (#1959)', () => { const result = await handler.execute('codegraph_search', { query: 'unicodeSymbol' }); expect(result.isError).toBeFalsy(); expect(result.content[0].text).toContain('cannot answer from this index'); - expect(result.structuredContent).toEqual({ freshness: { stale: [relative], unchecked: [] } }); + expect(result.content[0].text).toContain(`- ${relative}`); + // Claude Code would show structuredContent in place of this text (#2088). + expect(result).not.toHaveProperty('structuredContent'); expect(result).not.toHaveProperty('_cgAnswerFiles'); }); @@ -147,7 +149,8 @@ describe('a degraded index refuses answers from changed files (#1959)', () => { const result = await handler.execute('codegraph_impact', { symbol: 'alphaOnly' }); expect(result.isError).toBeFalsy(); expect(result.content[0].text).toContain('validation budget'); - expect((result.structuredContent!.freshness as { unchecked: string[] }).unchecked.length).toBeGreaterThan(0); + expect(result.content[0].text).toMatch(/validation budget for \d+ files/); + expect(result).not.toHaveProperty('structuredContent'); expect(result.content[0].text).not.toContain('**Impact'); }); diff --git a/__tests__/mcp-status-freshness.test.ts b/__tests__/mcp-status-freshness.test.ts index 19c14f7ce..923f77ae3 100644 --- a/__tests__/mcp-status-freshness.test.ts +++ b/__tests__/mcp-status-freshness.test.ts @@ -42,9 +42,8 @@ describe('MCP status freshness (#1959)', () => { fs.writeFileSync(path.join(root, 'add.ts'), 'export const added = 1;\n'); const result = await handler.execute('codegraph_status', {}); - expect(result.structuredContent).toEqual({ freshness: { - lastIndexedAt: cg.getLastIndexedAt(), changes: { added: 1, modified: 1, removed: 1 }, complete: true, - } }); + // Claude Code would show structuredContent in place of this text (#2088). + expect(result).not.toHaveProperty('structuredContent'); const changed = result.content[0].text; expect(changed).toContain('**Changes since index:** 1 added, 1 modified, 1 removed'); }); diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 7553ca5a6..fbb663a91 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -1538,7 +1538,6 @@ export interface ToolResult { _cgExploreEmission?: ExploreEmission; /** Internal structured provenance, preserved by query workers and stripped by execute. */ _cgAnswerFiles?: AnswerFile[]; - structuredContent?: Record; } /** @@ -2757,7 +2756,9 @@ export class ToolHandler { if (validation.unchecked.length > 20) lines.push(`- … ${validation.unchecked.length - 20} more (narrow the query)`); } lines.push('Retry after a successful codegraph sync, or narrow the query.'); - return { ...this.textResult(lines.join('\n')), structuredContent: { freshness: validation } }; + // Text only: Claude Code shows the model a result's structuredContent + // in place of its text (#2088), and the text carries every file. + return this.textResult(lines.join('\n')); } } // Record + STRIP before anything else touches the result: the emission is @@ -8006,9 +8007,8 @@ export class ToolHandler { } } - return { ...this.textResult(lines.join('\n')), structuredContent: { - freshness: { lastIndexedAt, changes, complete: changes !== null }, - } }; + // Text only, for the same reason as the stale-answer path (#2088). + return this.textResult(lines.join('\n')); } /**