fix(mcp): bold labels instead of ATX headings in tool results (#778) (#951)

MCP tool results used Markdown ATX headings (##/###/####) for section
headers — the status summary, each search hit, every file section in an
exploration — which Markdown-rendering clients (e.g. the Claude Code
VSCode extension) blow up to H1–H4 font size, filling the transcript with
oversized lines (worst on search/explore, where the noise scales with
result count). Swap them all for bold labels, which render at body size
while keeping the same structure. CLI/TTY output (ContextBuilder) is
unchanged — the issue notes it's fine.

The format is parse-coupled, so kept in sync:
- The explore truncation boundary and the offload chunker
  (reasoning/reasoner.ts) both key off the per-file header, now a unique
  `**`-prefixed marker emitted via a shared fileSectionHeader() helper.
- Updated the offload strip regexes and switched the opt-in report-style
  prompt off ATX headings (same client, same rendering issue).
- Updated test helpers (sectionFor, sourcedFiles, the callers
  section-boundary scan) that scanned the old markers.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-22 12:06:35 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent ace8d8a0d0
commit 3e1547bbe1
15 changed files with 106 additions and 81 deletions
+7 -6
View File
@@ -35,15 +35,16 @@ import CodeGraph from '../src/index';
// (the steer-to-explore phrasing changed when the Read invitation was removed).
const SKELETON_MARK = '· skeleton (signatures only';
/** Return the `#### <path> ...` section for a file basename, header through the
* line before the next `###`/`####` header (or end of output). */
/** Return the ``**`<path>`** ...`` section for a file basename, header through the
* line before the next bold header (or end of output). Headers are bold labels,
* not ATX headings (issue #778); file sections start with ``**` ``. */
function sectionFor(text: string, basename: string): string {
const lines = text.split('\n');
const start = lines.findIndex((l) => l.startsWith('#### ') && l.includes(basename));
const start = lines.findIndex((l) => l.startsWith('**`') && l.includes(basename));
if (start < 0) return '';
let end = lines.length;
for (let i = start + 1; i < lines.length; i++) {
if (lines[i].startsWith('### ') || lines[i].startsWith('#### ')) {
if (lines[i].startsWith('**')) {
end = i;
break;
}
@@ -284,7 +285,7 @@ export class YamlCodec extends Codec {
const text = result.content?.[0]?.text ?? '';
// Precondition: the spine must have formed, or nothing skeletonizes.
expect(text).toContain('## Flow (call path among the symbols you queried)');
expect(text).toContain('**Flow (call path among the symbols you queried)');
for (const [file, marker] of [
['bridge-interceptor.ts', 'BRIDGE_BODY_MARKER'],
@@ -345,7 +346,7 @@ export class YamlCodec extends Codec {
it('spares an off-spine sibling when the agent NAMED a callable in it (RealCall fix)', async () => {
const result = await handler.execute('codegraph_explore', { query: SPARE_QUERY, maxFiles: 15 });
const text = result.content?.[0]?.text ?? '';
expect(text).toContain('## Flow (call path among the symbols you queried)');
expect(text).toContain('**Flow (call path among the symbols you queried)');
// auth-interceptor.ts is an off-spine Interceptor sibling — would skeletonize —
// but the agent named its method `authenticate`, so it stays FULL.
+12 -12
View File
@@ -184,7 +184,7 @@ describe('codegraph_explore — dynamic boundaries', () => {
const res = await handler.execute('codegraph_explore', { query: 'routeSave onSave' });
const text = res.content[0].text as string;
expect(text).toContain('## Dynamic boundaries');
expect(text).toContain('**Dynamic boundaries');
expect(text).toContain('computed member call');
expect(text).toMatch(/router\.ts:6/); // the exact dispatch site
expect(text).toContain('candidates for key `save`');
@@ -212,7 +212,7 @@ describe('codegraph_explore — dynamic boundaries', () => {
const res = await handler.execute('codegraph_explore', { query: 'route onSave' });
const text = res.content[0].text as string;
expect(text).toContain('## Dynamic boundaries');
expect(text).toContain('**Dynamic boundaries');
expect(text).toContain('computed member call');
expect(text).not.toContain('candidates for key'); // runtime key → no shortlist to claim
});
@@ -234,7 +234,7 @@ describe('codegraph_explore — dynamic boundaries', () => {
// `processPayment` does not exist anywhere — only `route` resolves.
const res = await handler.execute('codegraph_explore', { query: 'route processPayment' });
const text = res.content[0].text as string;
expect(text).toContain('## Dynamic boundaries');
expect(text).toContain('**Dynamic boundaries');
});
it('renders a direct synthesized emit→handler hop as a dynamic-dispatch link (#687 criterion 1)', async () => {
@@ -267,11 +267,11 @@ describe('codegraph_explore — dynamic boundaries', () => {
const res = await handler.execute('codegraph_explore', { query: 'completeCheckout settleInvoice' });
const text = res.content[0].text as string;
expect(text).toContain('## Dynamic-dispatch links among your symbols');
expect(text).toContain('**Dynamic-dispatch links among your symbols');
expect(text).toMatch(/completeCheckout → settleInvoice/);
expect(text).toContain('invoice.settled');
// Connected via the synthesized edge — no boundary to announce.
expect(text).not.toContain('## Dynamic boundaries');
expect(text).not.toContain('**Dynamic boundaries');
});
it('never adds the section to a fully connected flow', async () => {
@@ -285,8 +285,8 @@ describe('codegraph_explore — dynamic boundaries', () => {
const res = await handler.execute('codegraph_explore', { query: 'stepOne stepThree' });
const text = res.content[0].text as string;
expect(text).toContain('## Flow');
expect(text).not.toContain('## Dynamic boundaries');
expect(text).toContain('**Flow');
expect(text).not.toContain('**Dynamic boundaries');
});
it('python getattr dispatch surfaces with a prefix-key candidate', async () => {
@@ -305,7 +305,7 @@ describe('codegraph_explore — dynamic boundaries', () => {
const res = await handler.execute('codegraph_explore', { query: 'process handle_save' });
const text = res.content[0].text as string;
expect(text).toContain('## Dynamic boundaries');
expect(text).toContain('**Dynamic boundaries');
expect(text).toContain('getattr');
expect(text).toContain('handle_save');
});
@@ -373,7 +373,7 @@ describe('codegraph_explore — interface dispatch', () => {
const res = await handler.execute('codegraph_explore', { query: 'processRunExecutionData executeNode execute' });
const text = res.content[0].text as string;
expect(text).toContain('## Interface dispatch (a named method has many implementations)');
expect(text).toContain('**Interface dispatch (a named method has many implementations)');
expect(text).toMatch(/`execute` → runtime dispatch to \*\*9\*\* types implementing `INodeType`/);
// a couple of concrete targets, with file:line
expect(text).toMatch(/\b\w+Node\.execute` \(/);
@@ -392,8 +392,8 @@ describe('codegraph_explore — interface dispatch', () => {
const res = await handler.execute('codegraph_explore', { query: 'stepOne stepThree' });
const text = res.content[0].text as string;
expect(text).toContain('## Flow');
expect(text).not.toContain('## Interface dispatch');
expect(text).toContain('**Flow');
expect(text).not.toContain('**Interface dispatch');
});
it('stays SILENT when the interface family is below the polymorphism threshold (3 impls)', async () => {
@@ -401,6 +401,6 @@ describe('codegraph_explore — interface dispatch', () => {
const res = await handler.execute('codegraph_explore', { query: 'processRunExecutionData executeNode execute' });
const text = res.content[0].text as string;
expect(text).not.toContain('## Interface dispatch');
expect(text).not.toContain('**Interface dispatch');
});
});
+1 -1
View File
@@ -55,7 +55,7 @@ describe('codegraph_explore — blast radius', () => {
const res = await handler.execute('codegraph_explore', { query: 'target' });
const text = res.content[0].text;
expect(text).toContain('### Blast radius');
expect(text).toContain('**Blast radius');
expect(text).toContain('`target`');
expect(text).toMatch(/caller/); // a caller count is reported
// It names WHERE (the caller file) — not the caller's source body.
@@ -28,11 +28,12 @@ import * as os from 'os';
import CodeGraph from '../src/index';
import { ToolHandler } from '../src/mcp/tools';
/** Paths that explore rendered as full-body `#### <path> —` source sections. */
/** Paths that explore rendered as full-body ``**`<path>`** —`` source sections.
* Headers are bold labels, not ATX headings (issue #778). */
function sourcedFiles(text: string): string[] {
const out: string[] = [];
for (const line of text.split('\n')) {
const m = line.match(/^#### (.+?) —/);
const m = line.match(/^\*\*`(.+?)`\*\* —/);
if (m) out.push(m[1].trim());
}
return out;
+2 -2
View File
@@ -206,8 +206,8 @@ describe('codegraph_explore output respects the adaptive budget', () => {
const text = result.content?.[0]?.text ?? '';
// Either there are relationships, or no edges were significant — both are fine.
// We just want to confirm we did not accidentally gate it off.
const hasRelationships = text.includes('### Relationships');
const sourceFollowsHeader = text.indexOf('### Source Code') > 0;
const hasRelationships = text.includes('**Relationships');
const sourceFollowsHeader = text.indexOf('**Source Code') > 0;
expect(hasRelationships || sourceFollowsHeader).toBe(true);
});
@@ -9,7 +9,7 @@
* "### Relationships" section would have caught it, but that is disabled below 500 files.
* Net: on a small RTK app the synthesized edge existed in the graph yet was invisible to
* the agent. The fix feeds a `dynNamed` set (named non-callable endpoints that participate
* in a heuristic edge) to the tier-independent "## Dynamic-dispatch links" scan. This test
* in a heuristic edge) to the tier-independent "**Dynamic-dispatch links**" scan. This test
* pins it on a deliberately tiny (<150-file) fixture so the Relationships gate is OFF and
* the dynamic-dispatch-links path is the ONLY thing that can surface the hop.
*/
@@ -77,7 +77,7 @@ export const outerThunk = createAsyncThunk('app/outer', async (n: number, { disp
// The synthesized hop now surfaces (was invisible: both endpoints `constant` AND the
// small-repo Relationships section is off).
expect(text).toContain('## Dynamic-dispatch links among your symbols');
expect(text).toContain('**Dynamic-dispatch links among your symbols');
expect(text).toMatch(/outerThunk\s+→\s+innerThunk/);
// It reads as a dynamic-dispatch bridge with its wiring site, not a bare `calls`.
expect(text).toMatch(/dynamic: redux thunk @/);
+2 -2
View File
@@ -175,7 +175,7 @@ describe('MCP staleness banner', () => {
const res = await handler.execute('codegraph_status', {});
const text = res.content[0].text;
expect(text).toContain('### Pending sync:');
expect(text).toContain('**Pending sync:');
expect(text).toContain('src/charlie-only.ts');
// Status embeds the info first-class, so the auto-banner is suppressed.
expect(text.startsWith('⚠️')).toBe(false);
@@ -204,7 +204,7 @@ describe('MCP staleness banner', () => {
const res = await handler.execute('codegraph_status', {});
const text = res.content[0].text;
expect(text).toContain('### Auto-sync disabled:');
expect(text).toContain('**Auto-sync disabled:');
expect(text).toContain('OS watch/file limit exhausted');
// status renders the notice inline, so the auto-banner is not also prepended.
expect(text.startsWith('⚠️')).toBe(false);
+1 -1
View File
@@ -99,7 +99,7 @@ describe('codegraph_node file-view (Read replacement)', () => {
it('symbolsOnly returns the structural map, not the source', async () => {
const out = await text({ file: 'a.ts', symbolsOnly: true });
expect(out).toContain('### Symbols');
expect(out).toContain('**Symbols');
expect(out).toContain('helper');
expect(out).toContain('Widget');
expect(out).not.toContain('return x + 1'); // bodies are NOT included in the map
+4 -4
View File
@@ -231,16 +231,16 @@ describe('reasoning offload', () => {
describe('stripAgentDirectives', () => {
it('drops the agent-directed header but keeps source sections', () => {
const ctx = [
'## Exploration: how does X work',
'**Exploration: how does X work**',
'Found 12 symbols across 3 files.',
'',
'#### src/a.ts — foo(function)',
'**`src/a.ts`** — foo(function)',
'code body',
].join('\n');
const stripped = stripAgentDirectives(ctx);
expect(stripped).not.toContain('## Exploration:');
expect(stripped).not.toContain('**Exploration:');
expect(stripped).not.toContain('Found 12 symbols');
expect(stripped).toContain('#### src/a.ts');
expect(stripped).toContain('**`src/a.ts`');
expect(stripped).toContain('code body');
});
});
+4 -1
View File
@@ -99,7 +99,10 @@ describe('same-named symbols across apps (#764)', () => {
expect(out).toContain('apps/billing/src/users/user.service.ts');
// …and the billing section must list the billing controller, not admin's.
const billingSection = out.slice(out.indexOf('apps/billing/src/users/user.service.ts'));
const billingBody = billingSection.slice(0, billingSection.indexOf('###', 3) > 0 ? billingSection.indexOf('###', 3) : undefined);
// The next definition heading is a line-start bold label (issue #778: ATX `###`
// headings became `**…**`); billingSection starts mid-heading, so `\n**` finds it.
const nextDef = billingSection.indexOf('\n**');
const billingBody = billingSection.slice(0, nextDef > 0 ? nextDef : undefined);
expect(billingBody).toContain('apps/billing/src/users/user.controller.ts');
expect(billingBody).not.toContain('apps/admin/src/users/user.controller.ts');
});