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
+56 -39
View File
@@ -134,7 +134,7 @@ export interface ExploreOutputBudget {
maxCharsPerFile: number;
/** Cluster gap threshold in lines — tighter clustering on small projects. */
gapThreshold: number;
/** Max symbols listed in the per-file header (`#### path — sym(kind), ...`). */
/** Max symbols listed in the per-file header (``**`path`** — sym(kind), ...``). */
maxSymbolsInFileHeader: number;
/** Max edges shown per relationship kind in the Relationships section. */
maxEdgesPerRelationshipKind: number;
@@ -326,6 +326,23 @@ function numberSourceLines(slice: string, firstLineNumber: number): string {
return out.join('\n');
}
/**
* Unique line-prefix for a per-file source section in codegraph_explore output.
* Issue #778: tool results dropped ATX headings (`####`, `##`, `###`) for bold
* labels so Markdown-rendering MCP clients (e.g. the Claude Code VSCode
* extension) stop blowing every header up to H1H4. The path is bold + a code
* span so it still reads as a header, and the leading ``**` `` stays a UNIQUE,
* greppable marker — no other explore line begins with it — that the explore
* truncation boundary (`handleExplore`) and the offload chunker
* (`reasoning/reasoner.ts`) both key off to cut on whole file sections.
*/
const FILE_SECTION_PREFIX = '**`';
function fileSectionHeader(filePath: string, suffix: string): string {
return suffix
? `${FILE_SECTION_PREFIX}${filePath}\`** — ${suffix}`
: `${FILE_SECTION_PREFIX}${filePath}\`**`;
}
/**
* Per-file staleness banner emitted at the top of a tool response when the
* file watcher has pending events for files referenced by the response.
@@ -1350,7 +1367,7 @@ export class ToolHandler {
private definitionHeading(group: Node[]): string {
const head = group[0]!;
const line = head.startLine ? `:${head.startLine}` : '';
return `### ${head.qualifiedName} (${head.kind}) — ${head.filePath}${line}`;
return `**${head.qualifiedName}** (${head.kind}) — ${head.filePath}${line}`;
}
/**
@@ -1408,7 +1425,7 @@ export class ToolHandler {
// agent never mistakes one app's callers for another's. Narrow with
// `file` to focus a single definition.
const lines: string[] = [
`## Callers of ${symbol}${groups.length} distinct definitions (narrow with \`file\`)`,
`**Callers of ${symbol}${groups.length} distinct definitions (narrow with \`file\`)**`,
];
for (const group of groups) {
const { callers, labels } = collect(group);
@@ -1478,7 +1495,7 @@ export class ToolHandler {
// Multiple DISTINCT definitions (#764): per-definition sections.
const lines: string[] = [
`## Callees of ${symbol}${groups.length} distinct definitions (narrow with \`file\`)`,
`**Callees of ${symbol}${groups.length} distinct definitions (narrow with \`file\`)**`,
];
for (const group of groups) {
const { callees, labels } = collect(group);
@@ -1547,7 +1564,7 @@ export class ToolHandler {
// merging unrelated same-named classes (one UserService per monorepo app)
// overstated impact and confused agents. Narrow with `file`.
const sections: string[] = [
`## Impact of ${symbol}${groups.length} distinct definitions (each with its own blast radius; narrow with \`file\`)`,
`**Impact of ${symbol}${groups.length} distinct definitions (each with its own blast radius; narrow with \`file\`)**`,
];
for (const group of groups) {
const head = group[0]!;
@@ -1765,7 +1782,7 @@ export class ToolHandler {
if (synthLines.length === 0 && !boundaries) return EMPTY;
const out: string[] = [];
if (synthLines.length) out.push(
'## Dynamic-dispatch links among your symbols',
'**Dynamic-dispatch links among your symbols**',
'(synthesized — the indirect hops grep/Read would reconstruct; the `@file:line` is the wiring site)',
'', ...synthLines, '');
if (boundaries) out.push(boundaries);
@@ -1880,7 +1897,7 @@ export class ToolHandler {
if (!hasMain && synthLines.length === 0 && !boundaryText && !polyText) return EMPTY;
const out: string[] = [];
if (hasMain) {
out.push('## Flow (call path among the symbols you queried)', '');
out.push('**Flow (call path among the symbols you queried)**', '');
for (let i = 0; i < best!.length; i++) {
const step = best![i]!;
if (step.edge) { const sy = this.synthEdgeNote(step.edge); out.push(`${sy ? sy.compact : step.edge.kind}`); }
@@ -1890,7 +1907,7 @@ export class ToolHandler {
}
if (synthLines.length) {
out.push(
'## Dynamic-dispatch links among your symbols',
'**Dynamic-dispatch links among your symbols**',
'(synthesized — the indirect hops grep/Read would reconstruct; the `@file:line` is the wiring site)',
'',
...synthLines,
@@ -1958,7 +1975,7 @@ export class ToolHandler {
}
if (notes.length === 0) return '';
return [
'## Dynamic boundaries (the static path ends at runtime dispatch)',
'**Dynamic boundaries (the static path ends at runtime dispatch)**',
'',
...notes,
'',
@@ -2045,7 +2062,7 @@ export class ToolHandler {
}
if (notes.length === 0) return '';
return [
'## Interface dispatch (a named method has many implementations)',
'**Interface dispatch (a named method has many implementations)**',
'',
...notes,
'',
@@ -2174,7 +2191,7 @@ export class ToolHandler {
if (entries.length === 0) return '';
return [
'### Blast radius — what depends on these (update/verify before editing)',
'**Blast radius — what depends on these (update/verify before editing)**',
'',
...entries,
'',
@@ -2643,7 +2660,7 @@ export class ToolHandler {
// Step 3: Build relationship map
const lines: string[] = [
`## Exploration: ${query}`,
`**Exploration: ${query}**`,
'',
`Found ${subgraph.nodes.size} symbols across ${fileGroups.size} files.`,
'',
@@ -2661,7 +2678,7 @@ export class ToolHandler {
);
if (budget.includeRelationships && significantEdges.length > 0) {
lines.push('### Relationships');
lines.push('**Relationships**');
lines.push('');
// Group edges by kind for readability
@@ -2748,7 +2765,7 @@ export class ToolHandler {
return false;
};
lines.push('### Source Code');
lines.push('**Source Code**');
lines.push('');
lines.push('> The code below is the **verbatim, current on-disk source** of these files — re-read from disk on this call and line-numbered, byte-for-byte identical to what the Read tool returns. It is NOT a summary, outline, or stale cache. Treat each block as a Read you have already performed: do not Read a file shown here.');
lines.push('');
@@ -2892,7 +2909,7 @@ export class ToolHandler {
const tag = bodyIds.size > 0
? 'focused (the methods you named in full, the rest as signatures — codegraph_explore a signature by name for its body; do NOT Read)'
: 'skeleton (signatures only — codegraph_explore a name for its full body; do NOT Read)';
lines.push(`#### ${filePath}${names} · ${tag}`, '', '```' + lang, skel.join('\n'), '```', '');
lines.push(fileSectionHeader(filePath, `${names} · ${tag}`), '', '```' + lang, skel.join('\n'), '```', '');
totalChars += skel.join('\n').length + 120;
filesIncluded++;
continue;
@@ -2933,7 +2950,7 @@ export class ToolHandler {
)];
const headerNames = uniqSymbols.slice(0, budget.maxSymbolsInFileHeader);
const omitted = uniqSymbols.length - headerNames.length;
const wholeHeader = `#### ${filePath}${omitted > 0 ? `${headerNames.join(', ')}, +${omitted} more` : headerNames.join(', ')}`;
const wholeHeader = fileSectionHeader(filePath, omitted > 0 ? `${headerNames.join(', ')}, +${omitted} more` : headerNames.join(', '));
if (!fileNecessary && totalChars + wholeSection.length + 200 > budget.maxOutputChars) {
// Don't slice a whole file mid-method: an incidental file that doesn't
@@ -3200,7 +3217,7 @@ export class ToolHandler {
const headerSuffix = omittedCount > 0
? `${headerSymbols.join(', ')}, +${omittedCount} more`
: headerSymbols.join(', ');
const fileHeader = `#### ${filePath}${headerSuffix}`;
const fileHeader = fileSectionHeader(filePath, headerSuffix);
// The total cap bounds INCIDENTAL files only. A file that DEFINES a symbol
// the agent named (or that's on the flow spine) renders even when the
@@ -3241,7 +3258,7 @@ export class ToolHandler {
.sort((a, b) => b[1].score - a[1].score);
const remainingFiles = [...remainingRelevant, ...peripheralFiles];
if (remainingFiles.length > 0) {
lines.push('### Not shown above — explore these names for their source');
lines.push('**Not shown above — explore these names for their source**');
lines.push('');
for (const [filePath, group] of remainingFiles.slice(0, 10)) {
const symbols = group.nodes.map(n => `${n.name}:${n.startLine}`).join(', ');
@@ -3290,13 +3307,13 @@ export class ToolHandler {
const hardCeiling = Math.min(Math.round(budget.maxOutputChars * 1.5), 25000);
if (output.length > hardCeiling) {
// Cut at a FILE-SECTION boundary (the last `#### ` header before the
// Cut at a FILE-SECTION boundary (the last ``**` `` file header before the
// ceiling) so we drop whole trailing file-sections rather than slicing
// through a method body — a half-rendered method just forces the Read this
// tool exists to prevent. Fall back to a line boundary only if no section
// header sits in the back half (degenerate single-giant-section case).
const cut = output.slice(0, hardCeiling);
const lastSection = cut.lastIndexOf('\n#### ');
const lastSection = cut.lastIndexOf('\n' + FILE_SECTION_PREFIX);
const boundary = lastSection > hardCeiling * 0.5 ? lastSection : cut.lastIndexOf('\n');
const safe = boundary > 0 ? cut.slice(0, boundary) : cut;
return this.textResult(safe + '\n\n... (output truncated to budget; the source above is complete and verbatim — treat it as already Read. For any area not covered, run another codegraph_explore with the specific names — do NOT Read these files.)');
@@ -3411,7 +3428,7 @@ export class ToolHandler {
const shownList = listed.slice(0, LIST_CAP);
out.push(
'',
'### Other definitions',
'**Other definitions**',
...shownList.map((n) => `- \`${n.name}\` (${n.kind}) — ${n.filePath}:${n.startLine}`),
);
if (listed.length > LIST_CAP) out.push(`- … +${listed.length - LIST_CAP} more`);
@@ -3493,7 +3510,7 @@ export class ToolHandler {
// symbolsOnly → the cheap structural overview, no source.
if (opts.symbolsOnly) {
const out = [`**${filePath}** — ${nodes.length} symbol${nodes.length === 1 ? '' : 's'}, ${depSummary}`, ''];
if (nodes.length) out.push(...symbolMap('### Symbols'));
if (nodes.length) out.push(...symbolMap('**Symbols**'));
else out.push('_No indexed symbols in this file._');
out.push('', '> Drop `symbolsOnly` (or pass `offset`/`limit`) to read the source, like Read.');
return this.textResult(this.truncateOutput(out.join('\n')));
@@ -3503,7 +3520,7 @@ export class ToolHandler {
// line is `key: <secret>`. Summarize by key and point to a real Read.
if (CONFIG_LEAF_LANGUAGES.has(resolved.language)) {
const out = [`**${filePath}** — configuration/data file, ${depSummary}`, ''];
if (nodes.length) out.push(...symbolMap('### Keys (values withheld for safety)'));
if (nodes.length) out.push(...symbolMap('**Keys (values withheld for safety)**'));
out.push('', '> Values may be secrets, so codegraph indexes keys only. Read the file directly if you need a value.');
return this.textResult(this.truncateOutput(out.join('\n')));
}
@@ -3517,7 +3534,7 @@ export class ToolHandler {
}
if (content === null) {
const out = [`**${filePath}** — could not read from disk (it may have moved since indexing). ${depSummary}`, ''];
if (nodes.length) out.push(...symbolMap('### Symbols'));
if (nodes.length) out.push(...symbolMap('**Symbols**'));
out.push('', `> Read \`${filePath}\` directly for its current content.`);
return this.textResult(this.truncateOutput(out.join('\n')));
}
@@ -3614,7 +3631,7 @@ export class ToolHandler {
const callees = collect(cg.getCallees(node.id));
const callers = collect(cg.getCallers(node.id));
if (callees.length === 0 && callers.length === 0) return '';
const lines: string[] = ['', '### Trail — codegraph_node any of these to follow it (no Read needed)'];
const lines: string[] = ['', '**Trail — codegraph_node any of these to follow it (no Read needed)**'];
if (callees.length > 0) {
lines.push(`**Calls →** ${callees.slice(0, TRAIL_CAP).map(fmt).join(', ')}${callees.length > TRAIL_CAP ? `, +${callees.length - TRAIL_CAP} more` : ''}`);
}
@@ -3650,7 +3667,7 @@ export class ToolHandler {
const mismatch = this.worktreeMismatchFor(args.projectPath as string | undefined);
const lines: string[] = [
'## CodeGraph Status',
'**CodeGraph Status**',
'',
];
if (mismatch) {
@@ -3681,7 +3698,7 @@ export class ToolHandler {
);
}
lines.push('', '### Nodes by Kind:');
lines.push('', '**Nodes by Kind:**');
for (const [kind, count] of Object.entries(stats.nodesByKind)) {
if ((count as number) > 0) {
@@ -3689,7 +3706,7 @@ export class ToolHandler {
}
}
lines.push('', '### Languages:');
lines.push('', '**Languages:**');
for (const [lang, count] of Object.entries(stats.filesByLanguage)) {
if ((count as number) > 0) {
lines.push(`- ${lang}: ${count}`);
@@ -3703,7 +3720,7 @@ export class ToolHandler {
if (cg.isWatcherDegraded()) {
lines.push(
'',
'### Auto-sync disabled:',
'**Auto-sync disabled:**',
`- ${cg.getWatcherDegradedReason() ?? 'live file watching stopped'}`,
'- The index is frozen; Read files directly for current content.'
);
@@ -3715,7 +3732,7 @@ export class ToolHandler {
// banners on other tool calls.
const pending = cg.getPendingFiles();
if (pending.length > 0) {
lines.push('', '### Pending sync:');
lines.push('', '**Pending sync:**');
const now = Date.now();
for (const p of pending) {
const ageMs = Math.max(0, now - p.lastSeenMs);
@@ -3806,7 +3823,7 @@ export class ToolHandler {
* Format files as a flat list
*/
private formatFilesFlat(files: { path: string; language: string; nodeCount: number }[], includeMetadata: boolean): string {
const lines: string[] = [`## Files (${files.length})`, ''];
const lines: string[] = [`**Files (${files.length})**`, ''];
for (const file of files.sort((a, b) => a.path.localeCompare(b.path))) {
if (includeMetadata) {
@@ -3831,13 +3848,13 @@ export class ToolHandler {
byLang.set(file.language, existing);
}
const lines: string[] = [`## Files by Language (${files.length} total)`, ''];
const lines: string[] = [`**Files by Language (${files.length} total)**`, ''];
// Sort languages by file count (descending)
const sortedLangs = [...byLang.entries()].sort((a, b) => b[1].length - a[1].length);
for (const [lang, langFiles] of sortedLangs) {
lines.push(`### ${lang} (${langFiles.length})`);
lines.push(`**${lang} (${langFiles.length})**`);
for (const file of langFiles.sort((a, b) => a.path.localeCompare(b.path))) {
if (includeMetadata) {
lines.push(`- ${file.path} (${file.nodeCount} symbols)`);
@@ -3889,7 +3906,7 @@ export class ToolHandler {
}
// Render tree
const lines: string[] = [`## Project Structure (${files.length} files)`, ''];
const lines: string[] = [`**Project Structure (${files.length} files)**`, ''];
const renderNode = (node: TreeNode, prefix: string, isLast: boolean, depth: number): void => {
if (maxDepth !== undefined && depth > maxDepth) return;
@@ -4102,13 +4119,13 @@ export class ToolHandler {
// =========================================================================
private formatSearchResults(results: SearchResult[]): string {
const lines: string[] = [`## Search Results (${results.length} found)`, ''];
const lines: string[] = [`**Search Results (${results.length} found)**`, ''];
for (const result of results) {
const { node } = result;
const location = node.startLine ? `:${node.startLine}` : '';
// Compact format: one line per result with key info
lines.push(`### ${node.name} (${node.kind})`);
lines.push(`**${node.name}** (${node.kind})`);
lines.push(`${node.filePath}${location}`);
if (node.signature) lines.push(`\`${node.signature}\``);
lines.push('');
@@ -4118,7 +4135,7 @@ export class ToolHandler {
}
private formatNodeList(nodes: Node[], title: string, labels?: Map<string, string>): string {
const lines: string[] = [`## ${title} (${nodes.length} found)`, ''];
const lines: string[] = [`**${title} (${nodes.length} found)**`, ''];
for (const node of nodes) {
const location = node.startLine ? `:${node.startLine}` : '';
@@ -4153,7 +4170,7 @@ export class ToolHandler {
// Compact format: just list affected symbols grouped by file
const lines: string[] = [
`## Impact: "${symbol}" affects ${nodeCount} symbols`,
`**Impact: "${symbol}" affects ${nodeCount} symbols**`,
'',
];
@@ -4201,7 +4218,7 @@ export class ToolHandler {
private formatNodeDetails(node: Node, code: string | null, outline?: string | null): string {
const location = node.startLine ? `:${node.startLine}` : '';
const lines: string[] = [
`## ${node.name} (${node.kind})`,
`**${node.name}** (${node.kind})`,
'',
`**Location:** ${node.filePath}${location}`,
];
+9 -7
View File
@@ -124,8 +124,8 @@ CORRECTNESS OVERRIDES EVERYTHING. Being incomplete is fine; being WRONG is not
const SYSTEM_PROMPT_REPORT = `${ROLE}
Produce a single self-contained exploration report, formatted exactly like the summary a thorough senior engineer hands back after investigating. Clean Markdown, in this shape:
- Open with the one-line coverage verdict (above). Then, ONLY if covered, a title: "## <Topic> — <Flow / Trace / Overview>". If coverage is not-found, the verdict + the names to explore next is the entire reply. NO preamble ("Here is", "Now I understand").
- Body is numbered sections with bold headers: "### 1. **<step or aspect>**", "### 2. **<...>**", …
- Open with the one-line coverage verdict (above). Then, ONLY if covered, a bold title: "**<Topic> — <Flow / Trace / Overview>**". If coverage is not-found, the verdict + the names to explore next is the entire reply. NO preamble ("Here is", "Now I understand"). Use bold labels for headers, never Markdown ATX headings (\`#\`/\`##\`) — they render oversized in some clients.
- Body is numbered sections with bold headers: "**1. <step or aspect>**", "**2. <...>**", …
- Cite every location inline and in bold as **\`path/to/file.ts:line\`** (or a line range), exactly as given in the source. Bold key classes, methods, and symbols.
- For a flow/path question, include a call-chain diagram in a fenced code block using down-arrows:
\`\`\`
@@ -135,7 +135,7 @@ Produce a single self-contained exploration report, formatted exactly like the s
\`\`\`
- Quote only the code lines that carry the logic, in fenced code blocks, keeping their line numbers. Keep snippets tight.
- Separate major sections with a "---" rule.
- End with "### Summary" — the end-to-end chain in one compact block.
- End with "**Summary**" — the end-to-end chain in one compact block.
Be precise and dense — an engineer should be able to act from this report without opening a file.`;
@@ -172,11 +172,13 @@ export function stripAgentDirectives(context: string): string {
let i = 0;
while (i < lines.length) {
const ln = lines[i] ?? '';
if (/^##\s+Exploration:/.test(ln) || /^Found \d+ symbols? across \d+ files?/.test(ln)) { i++; continue; }
// "Not shown above" pointer section: drop header + its bullets/blanks until the next rule/heading/blockquote.
if (/^###\s+Not shown above/i.test(ln)) {
// Headers are bold labels, not ATX headings (tools.ts, issue #778): the
// explore header is `**Exploration: …**`, file sections start with ``**` ``.
if (/^\*\*Exploration:/.test(ln) || /^Found \d+ symbols? across \d+ files?/.test(ln)) { i++; continue; }
// "Not shown above" pointer section: drop header + its bullets/blanks until the next rule/header/blockquote.
if (/^\*\*Not shown above/i.test(ln)) {
i++;
while (i < lines.length && !/^(---|#{2,4}\s|>\s)/.test(lines[i] ?? '')) i++;
while (i < lines.length && !/^(---|\*\*|>\s)/.test(lines[i] ?? '')) i++;
continue;
}
// Agent-directed blockquote notes (completeness / budget / trimmed).