From fdc7754a72065ac9a06702aa32ee3bf116a0658f Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Fri, 2 Oct 2026 04:16:48 +0000 Subject: [PATCH] fix(mcp): return status and stale refusals as text only (#2088) (#2284) * 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. (cherry picked from commit 18d65f8e106b1ee02e9d8a9cf482cce00fbc9dbe) * fix(mcp): list every unverified file in the stale refusal (#2088) (cherry picked from commit f1b0ec7f56e003d9fd6b1286f17a76e2287d43c6) * fix(mcp): keep the stale refusal's unchecked-file list bounded The text-only refusal now names the first 20 files it could not verify and counts the rest, as it did before; an impact answer over a hub symbol can leave thousands unchecked, and every one of them would land in the agent's context. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit c4513a66c9ae6bb10ede80d102d4122ed7f0508d) * docs(changelog): one entry for the text-only results fix Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Aaron Queen Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/mcp-stale-refusal.test.ts | 14 +++++++++++--- __tests__/mcp-status-freshness.test.ts | 5 ++--- src/mcp/tools.ts | 11 ++++++----- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d38859d8..392e10bb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -161,6 +161,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - The Claude Code prompt hook no longer runs on the messages Claude Code uses to hand a subagent's report back to the main session. Before, such a long report could keep the hook busy past Claude Code's 30-second hook timeout and inject context unrelated to what you asked. Thanks @danusha2345, and @tippmar-nr for the report. (#2184) - Newer MCP clients such as Antigravity 2.5 connect again: the server now answers their `server/discover` probe right away with "method not found", so they go straight on to the regular handshake instead of waiting on a reply that could take seconds or never come. Thanks @danusha2345, and @samem26 for the report. (#2084) - A CodeGraph session that queried another project through `projectPath` no longer keeps that project locked for as long as it runs: after 10 minutes without a query it lets the project go, so the project's own session and `codegraph index` can take over again (tune with `CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS`, `0` keeps it open). Thanks @danusha2345, and @bompus for the report. (#2087) +- 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. Both results are now text only. Thanks @bompus. (#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..309cf92ee 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'); }); @@ -137,7 +139,7 @@ describe('a degraded index refuses answers from changed files (#1959)', () => { ); it('refuses rather than silently validating only the first 200 contributing files', async () => { - for (let i = 0; i < 201; i++) { + for (let i = 0; i < 230; i++) { fs.writeFileSync(path.join(root, `caller${i}.ts`), `import { alphaOnly } from './alpha'; export function caller${i}() { return alphaOnly(); }\n`); } @@ -147,7 +149,13 @@ 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); + const text = result.content[0].text; + const total = Number(text.match(/validation budget for (\d+) files/)![1]); + // A long unchecked list stays bounded: the first 20 and a count of the rest. + expect(total).toBeGreaterThan(20); + expect(text.split('\n').filter(line => /^- (?!…)/.test(line))).toHaveLength(20); + expect(text).toContain(`- … ${total - 20} more (narrow the query)`); + 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 d11fa40c5..3aa5fab87 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; } /** @@ -2811,7 +2810,10 @@ 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). The text names every stale file and + // the first unchecked ones; the rest only need a narrower query. + return this.textResult(lines.join('\n')); } } // Record + STRIP before anything else touches the result: the emission is @@ -8060,9 +8062,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')); } /**