Files
codegraph/src/mcp/tools.ts
T
Colby McHenry 789158bfd4 Bump version to 0.7.2
Updates Swift and Kotlin language support from basic to full in documentation and reduces explore budget thresholds to optimize performance for smaller codebases.
2026-04-07 16:56:41 -05:00

1353 lines
45 KiB
TypeScript

/**
* MCP Tool Definitions
*
* Defines the tools exposed by the CodeGraph MCP server.
*/
import CodeGraph, { findNearestCodeGraphRoot } from '../index';
import type { Node, Edge, SearchResult, Subgraph, TaskContext, NodeKind } from '../types';
import { createHash } from 'crypto';
import { writeFileSync, readFileSync, existsSync } from 'fs';
import { clamp, validatePathWithinRoot } from '../utils';
import { tmpdir } from 'os';
import { join } from 'path';
/** Maximum output length to prevent context bloat (characters) */
const MAX_OUTPUT_LENGTH = 15000;
/**
* Calculate the recommended number of codegraph_explore calls based on project size.
* Larger codebases need more exploration calls to cover their surface area,
* but smaller ones should use fewer to avoid unnecessary overhead.
*/
export function getExploreBudget(fileCount: number): number {
if (fileCount < 500) return 1;
if (fileCount < 5000) return 2;
if (fileCount < 15000) return 3;
if (fileCount < 25000) return 4;
return 5;
}
/**
* Mark a Claude session as having consulted MCP tools.
* This enables Grep/Glob/Bash commands that would otherwise be blocked.
*/
function markSessionConsulted(sessionId: string): void {
try {
const hash = createHash('md5').update(sessionId).digest('hex').slice(0, 16);
const markerPath = join(tmpdir(), `codegraph-consulted-${hash}`);
writeFileSync(markerPath, new Date().toISOString(), 'utf8');
} catch {
// Silently fail - don't break MCP on marker write failure
}
}
/**
* MCP Tool definition
*/
export interface ToolDefinition {
name: string;
description: string;
inputSchema: {
type: 'object';
properties: Record<string, PropertySchema>;
required?: string[];
};
}
interface PropertySchema {
type: string;
description: string;
enum?: string[];
default?: unknown;
}
/**
* Tool execution result
*/
export interface ToolResult {
content: Array<{
type: 'text';
text: string;
}>;
isError?: boolean;
}
/**
* Common projectPath property for cross-project queries
*/
const projectPathProperty: PropertySchema = {
type: 'string',
description: 'Path to a different project with .codegraph/ initialized. If omitted, uses current project. Use this to query other codebases.',
};
/**
* All CodeGraph MCP tools
*
* Designed for minimal context usage - use codegraph_context as the primary tool,
* and only use other tools for targeted follow-up queries.
*
* All tools support cross-project queries via the optional `projectPath` parameter.
*/
export const tools: ToolDefinition[] = [
{
name: 'codegraph_search',
description: 'Quick symbol search by name. Returns locations only (no code). Use codegraph_context instead for comprehensive task context.',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Symbol name or partial name (e.g., "auth", "signIn", "UserService")',
},
kind: {
type: 'string',
description: 'Filter by node kind',
enum: ['function', 'method', 'class', 'interface', 'type', 'variable', 'route', 'component'],
},
limit: {
type: 'number',
description: 'Maximum results (default: 10)',
default: 10,
},
projectPath: projectPathProperty,
},
required: ['query'],
},
},
{
name: 'codegraph_context',
description: 'PRIMARY TOOL: Build comprehensive context for a task. Returns entry points, related symbols, and key code - often enough to understand the codebase without additional tool calls. NOTE: This provides CODE context, not product requirements. For new features, still clarify UX/behavior questions with the user before implementing.',
inputSchema: {
type: 'object',
properties: {
task: {
type: 'string',
description: 'Description of the task, bug, or feature to build context for',
},
maxNodes: {
type: 'number',
description: 'Maximum symbols to include (default: 20)',
default: 20,
},
includeCode: {
type: 'boolean',
description: 'Include code snippets for key symbols (default: true)',
default: true,
},
projectPath: projectPathProperty,
},
required: ['task'],
},
},
{
name: 'codegraph_callers',
description: 'Find all functions/methods that call a specific symbol. Useful for understanding usage patterns and impact of changes.',
inputSchema: {
type: 'object',
properties: {
symbol: {
type: 'string',
description: 'Name of the function, method, or class to find callers for',
},
limit: {
type: 'number',
description: 'Maximum number of callers to return (default: 20)',
default: 20,
},
projectPath: projectPathProperty,
},
required: ['symbol'],
},
},
{
name: 'codegraph_callees',
description: 'Find all functions/methods that a specific symbol calls. Useful for understanding dependencies and code flow.',
inputSchema: {
type: 'object',
properties: {
symbol: {
type: 'string',
description: 'Name of the function, method, or class to find callees for',
},
limit: {
type: 'number',
description: 'Maximum number of callees to return (default: 20)',
default: 20,
},
projectPath: projectPathProperty,
},
required: ['symbol'],
},
},
{
name: 'codegraph_impact',
description: 'Analyze the impact radius of changing a symbol. Shows what code could be affected by modifications.',
inputSchema: {
type: 'object',
properties: {
symbol: {
type: 'string',
description: 'Name of the symbol to analyze impact for',
},
depth: {
type: 'number',
description: 'How many levels of dependencies to traverse (default: 2)',
default: 2,
},
projectPath: projectPathProperty,
},
required: ['symbol'],
},
},
{
name: 'codegraph_node',
description: 'Get detailed information about a specific code symbol. Use includeCode=true only when you need the full source code - otherwise just get location and signature to minimize context usage.',
inputSchema: {
type: 'object',
properties: {
symbol: {
type: 'string',
description: 'Name of the symbol to get details for',
},
includeCode: {
type: 'boolean',
description: 'Include full source code (default: false to minimize context)',
default: false,
},
projectPath: projectPathProperty,
},
required: ['symbol'],
},
},
{
name: 'codegraph_explore',
description: 'Deep exploration tool — returns comprehensive context for a topic in a SINGLE call. Groups all relevant source code by file (contiguous sections, not snippets), includes a relationship map, and uses deeper graph traversal. Designed to replace multiple codegraph_node + file Read calls. Use this instead of codegraph_context when you need thorough understanding.',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'What you want to understand (e.g., "undo redo system", "authentication flow", "how routing works")',
},
maxFiles: {
type: 'number',
description: 'Maximum number of files to include source code from (default: 12)',
default: 12,
},
projectPath: projectPathProperty,
},
required: ['query'],
},
},
{
name: 'codegraph_status',
description: 'Get the status of the CodeGraph index, including statistics about indexed files, nodes, and edges.',
inputSchema: {
type: 'object',
properties: {
projectPath: projectPathProperty,
},
},
},
{
name: 'codegraph_files',
description: 'REQUIRED for file/folder exploration. Get the project file structure from the CodeGraph index. Returns a tree view of all indexed files with metadata (language, symbol count). Much faster than Glob/filesystem scanning. Use this FIRST when exploring project structure, finding files, or understanding codebase organization.',
inputSchema: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'Filter to files under this directory path (e.g., "src/components"). Returns all files if not specified.',
},
pattern: {
type: 'string',
description: 'Filter files matching this glob pattern (e.g., "*.tsx", "**/*.test.ts")',
},
format: {
type: 'string',
description: 'Output format: "tree" (hierarchical, default), "flat" (simple list), "grouped" (by language)',
enum: ['tree', 'flat', 'grouped'],
default: 'tree',
},
includeMetadata: {
type: 'boolean',
description: 'Include file metadata like language and symbol count (default: true)',
default: true,
},
maxDepth: {
type: 'number',
description: 'Maximum directory depth to show (default: unlimited)',
},
projectPath: projectPathProperty,
},
},
},
];
/**
* Tool handler that executes tools against a CodeGraph instance
*
* Supports cross-project queries via the projectPath parameter.
* Other projects are opened on-demand and cached for performance.
*/
export class ToolHandler {
// Cache of opened CodeGraph instances for cross-project queries
private projectCache: Map<string, CodeGraph> = new Map();
constructor(private cg: CodeGraph | null) {}
/**
* Update the default CodeGraph instance (e.g. after lazy initialization)
*/
setDefaultCodeGraph(cg: CodeGraph): void {
this.cg = cg;
}
/**
* Whether a default CodeGraph instance is available
*/
hasDefaultCodeGraph(): boolean {
return this.cg !== null;
}
/**
* Get tool definitions with dynamic descriptions based on project size.
* The codegraph_explore tool description includes a budget recommendation
* scaled to the number of indexed files.
*/
getTools(): ToolDefinition[] {
if (!this.cg) return tools;
try {
const stats = this.cg.getStats();
const budget = getExploreBudget(stats.fileCount);
return tools.map(tool => {
if (tool.name === 'codegraph_explore') {
return {
...tool,
description: `${tool.description} Budget: make at most ${budget} calls for this project (${stats.fileCount.toLocaleString()} files indexed).`,
};
}
return tool;
});
} catch {
return tools;
}
}
/**
* Get CodeGraph instance for a project
*
* If projectPath is provided, opens that project's CodeGraph (cached).
* Otherwise returns the default CodeGraph instance.
*
* Walks up parent directories to find the nearest .codegraph/ folder,
* similar to how git finds .git/ directories.
*/
private getCodeGraph(projectPath?: string): CodeGraph {
if (!projectPath) {
if (!this.cg) {
throw new Error('CodeGraph not initialized for this project. Run \'codegraph init\' first.');
}
return this.cg;
}
// Check cache first (using original path as key)
if (this.projectCache.has(projectPath)) {
return this.projectCache.get(projectPath)!;
}
// Walk up parent directories to find nearest .codegraph/
const resolvedRoot = findNearestCodeGraphRoot(projectPath);
if (!resolvedRoot) {
throw new Error(`CodeGraph not initialized in ${projectPath}. Run 'codegraph init' in that project first.`);
}
// Check if we already have this resolved root cached (different path, same project)
if (this.projectCache.has(resolvedRoot)) {
const cg = this.projectCache.get(resolvedRoot)!;
// Cache under original path too for faster future lookups
this.projectCache.set(projectPath, cg);
return cg;
}
// Open and cache under both paths
const cg = CodeGraph.openSync(resolvedRoot);
this.projectCache.set(resolvedRoot, cg);
if (projectPath !== resolvedRoot) {
this.projectCache.set(projectPath, cg);
}
return cg;
}
/**
* Close all cached project connections
*/
closeAll(): void {
for (const cg of this.projectCache.values()) {
cg.close();
}
this.projectCache.clear();
}
/**
* Validate that a value is a non-empty string
*/
private validateString(value: unknown, name: string): string | ToolResult {
if (typeof value !== 'string' || value.length === 0) {
return this.errorResult(`${name} must be a non-empty string`);
}
return value;
}
/**
* Execute a tool by name
*/
async execute(toolName: string, args: Record<string, unknown>): Promise<ToolResult> {
try {
switch (toolName) {
case 'codegraph_search':
return await this.handleSearch(args);
case 'codegraph_context':
return await this.handleContext(args);
case 'codegraph_callers':
return await this.handleCallers(args);
case 'codegraph_callees':
return await this.handleCallees(args);
case 'codegraph_impact':
return await this.handleImpact(args);
case 'codegraph_explore':
return await this.handleExplore(args);
case 'codegraph_node':
return await this.handleNode(args);
case 'codegraph_status':
return await this.handleStatus(args);
case 'codegraph_files':
return await this.handleFiles(args);
default:
return this.errorResult(`Unknown tool: ${toolName}`);
}
} catch (err) {
return this.errorResult(`Tool execution failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
/**
* Handle codegraph_search
*/
private async handleSearch(args: Record<string, unknown>): Promise<ToolResult> {
const query = this.validateString(args.query, 'query');
if (typeof query !== 'string') return query;
const cg = this.getCodeGraph(args.projectPath as string | undefined);
const kind = args.kind as string | undefined;
const rawLimit = Number(args.limit) || 10;
const limit = clamp(rawLimit, 1, 100);
const results = cg.searchNodes(query, {
limit,
kinds: kind ? [kind as NodeKind] : undefined,
});
if (results.length === 0) {
return this.textResult(`No results found for "${query}"`);
}
const formatted = this.formatSearchResults(results);
return this.textResult(this.truncateOutput(formatted));
}
/**
* Handle codegraph_context
*/
private async handleContext(args: Record<string, unknown>): Promise<ToolResult> {
const task = this.validateString(args.task, 'task');
if (typeof task !== 'string') return task;
// Mark session as consulted (enables Grep/Glob/Bash)
const sessionId = process.env.CLAUDE_SESSION_ID;
if (sessionId) {
markSessionConsulted(sessionId);
}
const cg = this.getCodeGraph(args.projectPath as string | undefined);
const maxNodes = (args.maxNodes as number) || 20;
const includeCode = args.includeCode !== false;
const context = await cg.buildContext(task, {
maxNodes,
includeCode,
format: 'markdown',
});
// Detect if this looks like a feature request (vs bug fix or exploration)
const isFeatureQuery = this.looksLikeFeatureRequest(task);
const reminder = isFeatureQuery
? '\n\n⚠️ **Ask user:** UX preferences, edge cases, acceptance criteria'
: '';
// buildContext returns string when format is 'markdown'
if (typeof context === 'string') {
return this.textResult(context + reminder);
}
// If it returns TaskContext, format it
return this.textResult(this.formatTaskContext(context) + reminder);
}
/**
* Heuristic to detect if a query looks like a feature request
*/
private looksLikeFeatureRequest(task: string): boolean {
const featureKeywords = [
'add', 'create', 'implement', 'build', 'enable', 'allow',
'new feature', 'support for', 'ability to', 'want to',
'should be able', 'need to add', 'swap', 'edit', 'modify'
];
const bugKeywords = [
'fix', 'bug', 'error', 'broken', 'crash', 'issue', 'problem',
'not working', 'fails', 'undefined', 'null'
];
const explorationKeywords = [
'how does', 'where is', 'what is', 'find', 'show me',
'explain', 'understand', 'explore'
];
const lowerTask = task.toLowerCase();
// If it's clearly a bug or exploration, not a feature
if (bugKeywords.some(k => lowerTask.includes(k))) return false;
if (explorationKeywords.some(k => lowerTask.includes(k))) return false;
// If it matches feature keywords, it's likely a feature request
return featureKeywords.some(k => lowerTask.includes(k));
}
/**
* Handle codegraph_callers
*/
private async handleCallers(args: Record<string, unknown>): Promise<ToolResult> {
const symbol = this.validateString(args.symbol, 'symbol');
if (typeof symbol !== 'string') return symbol;
const cg = this.getCodeGraph(args.projectPath as string | undefined);
const limit = clamp((args.limit as number) || 20, 1, 100);
const allMatches = this.findAllSymbols(cg, symbol);
if (allMatches.nodes.length === 0) {
return this.textResult(`Symbol "${symbol}" not found in the codebase`);
}
// Aggregate callers across all matching symbols
const seen = new Set<string>();
const allCallers: Node[] = [];
for (const node of allMatches.nodes) {
for (const c of cg.getCallers(node.id)) {
if (!seen.has(c.node.id)) {
seen.add(c.node.id);
allCallers.push(c.node);
}
}
}
if (allCallers.length === 0) {
return this.textResult(`No callers found for "${symbol}"${allMatches.note}`);
}
const formatted = this.formatNodeList(allCallers.slice(0, limit), `Callers of ${symbol}`) + allMatches.note;
return this.textResult(this.truncateOutput(formatted));
}
/**
* Handle codegraph_callees
*/
private async handleCallees(args: Record<string, unknown>): Promise<ToolResult> {
const symbol = this.validateString(args.symbol, 'symbol');
if (typeof symbol !== 'string') return symbol;
const cg = this.getCodeGraph(args.projectPath as string | undefined);
const limit = clamp((args.limit as number) || 20, 1, 100);
const allMatches = this.findAllSymbols(cg, symbol);
if (allMatches.nodes.length === 0) {
return this.textResult(`Symbol "${symbol}" not found in the codebase`);
}
// Aggregate callees across all matching symbols
const seen = new Set<string>();
const allCallees: Node[] = [];
for (const node of allMatches.nodes) {
for (const c of cg.getCallees(node.id)) {
if (!seen.has(c.node.id)) {
seen.add(c.node.id);
allCallees.push(c.node);
}
}
}
if (allCallees.length === 0) {
return this.textResult(`No callees found for "${symbol}"${allMatches.note}`);
}
const formatted = this.formatNodeList(allCallees.slice(0, limit), `Callees of ${symbol}`) + allMatches.note;
return this.textResult(this.truncateOutput(formatted));
}
/**
* Handle codegraph_impact
*/
private async handleImpact(args: Record<string, unknown>): Promise<ToolResult> {
const symbol = this.validateString(args.symbol, 'symbol');
if (typeof symbol !== 'string') return symbol;
const cg = this.getCodeGraph(args.projectPath as string | undefined);
const depth = clamp((args.depth as number) || 2, 1, 10);
const allMatches = this.findAllSymbols(cg, symbol);
if (allMatches.nodes.length === 0) {
return this.textResult(`Symbol "${symbol}" not found in the codebase`);
}
// Aggregate impact across all matching symbols
const mergedNodes = new Map<string, Node>();
const mergedEdges: Edge[] = [];
const seenEdges = new Set<string>();
for (const node of allMatches.nodes) {
const impact = cg.getImpactRadius(node.id, depth);
for (const [id, n] of impact.nodes) {
mergedNodes.set(id, n);
}
for (const e of impact.edges) {
const key = `${e.source}->${e.target}:${e.kind}`;
if (!seenEdges.has(key)) {
seenEdges.add(key);
mergedEdges.push(e);
}
}
}
const mergedImpact = {
nodes: mergedNodes,
edges: mergedEdges,
roots: allMatches.nodes.map(n => n.id),
};
const formatted = this.formatImpact(symbol, mergedImpact) + allMatches.note;
return this.textResult(this.truncateOutput(formatted));
}
/** Maximum output for explore tool — sized to stay under MCP client token limits (~10k tokens) */
private static readonly EXPLORE_MAX_OUTPUT = 35000;
/**
* Handle codegraph_explore — deep exploration in a single call
*
* Strategy: find relevant symbols via graph traversal, group by file,
* then read contiguous file sections covering all symbols per file.
* This replaces multiple codegraph_node + Read calls.
*/
private async handleExplore(args: Record<string, unknown>): Promise<ToolResult> {
const query = this.validateString(args.query, 'query');
if (typeof query !== 'string') return query;
const cg = this.getCodeGraph(args.projectPath as string | undefined);
const maxFiles = clamp((args.maxFiles as number) || 12, 1, 20);
const projectRoot = cg.getProjectRoot();
// Step 1: Find relevant context with generous parameters
const subgraph = await cg.findRelevantContext(query, {
searchLimit: 8,
traversalDepth: 3,
maxNodes: 80,
minScore: 0.2,
});
if (subgraph.nodes.size === 0) {
return this.textResult(`No relevant code found for "${query}"`);
}
// Step 2: Group nodes by file, score by relevance
const fileGroups = new Map<string, { nodes: Node[]; score: number }>();
const entryNodeIds = new Set(subgraph.roots);
// Build a set of nodes directly connected to entry points (depth 1)
const connectedToEntry = new Set<string>();
for (const edge of subgraph.edges) {
if (entryNodeIds.has(edge.source)) connectedToEntry.add(edge.target);
if (entryNodeIds.has(edge.target)) connectedToEntry.add(edge.source);
}
for (const node of subgraph.nodes.values()) {
// Skip import/export nodes — they add noise without information
if (node.kind === 'import' || node.kind === 'export') continue;
const group = fileGroups.get(node.filePath) || { nodes: [], score: 0 };
group.nodes.push(node);
// Score: entry point nodes worth 10, directly connected worth 3, others worth 1
if (entryNodeIds.has(node.id)) {
group.score += 10;
} else if (connectedToEntry.has(node.id)) {
group.score += 3;
} else {
group.score += 1;
}
fileGroups.set(node.filePath, group);
}
// Only include files that have entry points or nodes directly connected to entry points
const relevantFiles = [...fileGroups.entries()].filter(([, group]) => group.score >= 3);
// Extract query terms for relevance checking
const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length >= 3);
// Sort files: highest relevance first, deprioritize low-value files
const sortedFiles = relevantFiles.sort((a, b) => {
const aPath = a[0].toLowerCase();
const bPath = b[0].toLowerCase();
// Check if any node name or file path relates to query terms
const hasQueryRelevance = (filePath: string, nodes: Node[]) => {
const fp = filePath.toLowerCase();
if (queryTerms.some(t => fp.includes(t))) return true;
return nodes.some(n => queryTerms.some(t => n.name.toLowerCase().includes(t)));
};
const aRelevant = hasQueryRelevance(aPath, a[1].nodes);
const bRelevant = hasQueryRelevance(bPath, b[1].nodes);
if (aRelevant !== bRelevant) return aRelevant ? -1 : 1;
// Deprioritize test files, icon files, and i18n files
const isLowValue = (p: string) =>
/\/(tests?|__tests?__|spec)\//i.test(p) ||
/\bicons?\b/i.test(p) ||
/\bi18n\b/i.test(p);
const aLow = isLowValue(aPath);
const bLow = isLowValue(bPath);
if (aLow !== bLow) return aLow ? 1 : -1;
if (a[1].score !== b[1].score) return b[1].score - a[1].score;
return b[1].nodes.length - a[1].nodes.length;
});
// Step 3: Build relationship map
const lines: string[] = [
`## Exploration: ${query}`,
'',
`Found ${subgraph.nodes.size} symbols across ${fileGroups.size} files.`,
'',
];
// Relationship map — show how symbols connect
const significantEdges = subgraph.edges.filter(e =>
e.kind !== 'contains' // skip contains — it's implied by file grouping
);
if (significantEdges.length > 0) {
lines.push('### Relationships');
lines.push('');
// Group edges by kind for readability
const byKind = new Map<string, Array<{ source: string; target: string }>>();
for (const edge of significantEdges) {
const sourceNode = subgraph.nodes.get(edge.source);
const targetNode = subgraph.nodes.get(edge.target);
if (!sourceNode || !targetNode) continue;
const group = byKind.get(edge.kind) || [];
group.push({ source: sourceNode.name, target: targetNode.name });
byKind.set(edge.kind, group);
}
for (const [kind, edges] of byKind) {
// Show up to 15 relationships per kind
const shown = edges.slice(0, 15);
lines.push(`**${kind}:**`);
for (const e of shown) {
lines.push(`- ${e.source}${e.target}`);
}
if (edges.length > 15) {
lines.push(`- ... and ${edges.length - 15} more`);
}
lines.push('');
}
}
// Step 4: Read contiguous file sections
lines.push('### Source Code');
lines.push('');
let totalChars = lines.join('\n').length;
let filesIncluded = 0;
for (const [filePath, group] of sortedFiles) {
if (filesIncluded >= maxFiles) break;
if (totalChars > ToolHandler.EXPLORE_MAX_OUTPUT * 0.9) break;
const absPath = validatePathWithinRoot(projectRoot, filePath);
if (!absPath || !existsSync(absPath)) continue;
let fileContent: string;
try {
fileContent = readFileSync(absPath, 'utf-8');
} catch {
continue;
}
const fileLines = fileContent.split('\n');
const lang = group.nodes[0]?.language || '';
// Cluster nearby symbols to avoid reading huge gaps between distant symbols.
// Sort by start line, then merge overlapping/adjacent ranges (within 15 lines).
const ranges = group.nodes
.filter(n => n.startLine > 0 && n.endLine > 0)
.map(n => ({ start: n.startLine, end: n.endLine, name: n.name, kind: n.kind }))
.sort((a, b) => a.start - b.start);
if (ranges.length === 0) continue;
const GAP_THRESHOLD = 15; // merge sections within 15 lines of each other
const clusters: Array<{ start: number; end: number; symbols: string[] }> = [];
let current = { start: ranges[0]!.start, end: ranges[0]!.end, symbols: [`${ranges[0]!.name}(${ranges[0]!.kind})`] };
for (let i = 1; i < ranges.length; i++) {
const r = ranges[i]!;
if (r.start <= current.end + GAP_THRESHOLD) {
current.end = Math.max(current.end, r.end);
current.symbols.push(`${r.name}(${r.kind})`);
} else {
clusters.push(current);
current = { start: r.start, end: r.end, symbols: [`${r.name}(${r.kind})`] };
}
}
clusters.push(current);
// Build file section output from clusters
const contextPadding = 3;
let fileSection = '';
const allSymbols: string[] = [];
for (const cluster of clusters) {
const startIdx = Math.max(0, cluster.start - 1 - contextPadding);
const endIdx = Math.min(fileLines.length, cluster.end + contextPadding);
const section = fileLines.slice(startIdx, endIdx).join('\n');
if (fileSection.length > 0) {
fileSection += '\n\n// ... (gap) ...\n\n';
}
fileSection += section;
allSymbols.push(...cluster.symbols);
}
// Skip if this section would blow the output limit
if (totalChars + fileSection.length + 200 > ToolHandler.EXPLORE_MAX_OUTPUT) {
const budget = ToolHandler.EXPLORE_MAX_OUTPUT - totalChars - 200;
if (budget < 500) break;
const trimmed = fileSection.slice(0, budget) + '\n// ... trimmed ...';
lines.push(`#### ${filePath}${allSymbols.join(', ')}`);
lines.push('');
lines.push('```' + lang);
lines.push(trimmed);
lines.push('```');
lines.push('');
totalChars += trimmed.length + 200;
filesIncluded++;
break;
}
lines.push(`#### ${filePath}${allSymbols.join(', ')}`);
lines.push('');
lines.push('```' + lang);
lines.push(fileSection);
lines.push('```');
lines.push('');
totalChars += fileSection.length + 200;
filesIncluded++;
}
// Add remaining files as references (from both relevant and peripheral files)
const remainingRelevant = sortedFiles.slice(filesIncluded);
const peripheralFiles = [...fileGroups.entries()]
.filter(([, group]) => group.score < 3)
.sort((a, b) => b[1].score - a[1].score);
const remainingFiles = [...remainingRelevant, ...peripheralFiles];
if (remainingFiles.length > 0) {
lines.push('### Additional relevant files (not shown)');
lines.push('');
for (const [filePath, group] of remainingFiles.slice(0, 10)) {
const symbols = group.nodes.map(n => `${n.name}:${n.startLine}`).join(', ');
lines.push(`- ${filePath}: ${symbols}`);
}
if (remainingFiles.length > 10) {
lines.push(`- ... and ${remainingFiles.length - 10} more files`);
}
}
// Add completeness signal so agents know they don't need to re-read these files
lines.push('');
lines.push('---');
lines.push(`> **Complete source code is included above for ${filesIncluded} files.** You do NOT need to re-read these files — the relevant sections are already shown in full. Only use Read/Grep for files listed under "Additional relevant files" if you need more detail.`);
// Add explore budget note based on project size
try {
const stats = cg.getStats();
const budget = getExploreBudget(stats.fileCount);
lines.push('');
lines.push(`> **Explore budget: ${budget} calls max for this project (${stats.fileCount.toLocaleString()} files indexed).** Stop exploring and synthesize your answer once you've used ${budget} calls — do NOT make additional explore calls beyond this budget.`);
} catch {
// Stats unavailable — skip budget note
}
return this.textResult(lines.join('\n'));
}
/**
* Handle codegraph_node
*/
private async handleNode(args: Record<string, unknown>): Promise<ToolResult> {
const symbol = this.validateString(args.symbol, 'symbol');
if (typeof symbol !== 'string') return symbol;
const cg = this.getCodeGraph(args.projectPath as string | undefined);
// Default to false to minimize context usage
const includeCode = args.includeCode === true;
const match = this.findSymbol(cg, symbol);
if (!match) {
return this.textResult(`Symbol "${symbol}" not found in the codebase`);
}
let code: string | null = null;
if (includeCode) {
code = await cg.getCode(match.node.id);
}
const formatted = this.formatNodeDetails(match.node, code) + match.note;
return this.textResult(this.truncateOutput(formatted));
}
/**
* Handle codegraph_status
*/
private async handleStatus(args: Record<string, unknown>): Promise<ToolResult> {
const cg = this.getCodeGraph(args.projectPath as string | undefined);
const stats = cg.getStats();
const lines: string[] = [
'## CodeGraph Status',
'',
`**Files indexed:** ${stats.fileCount}`,
`**Total nodes:** ${stats.nodeCount}`,
`**Total edges:** ${stats.edgeCount}`,
`**Database size:** ${(stats.dbSizeBytes / 1024 / 1024).toFixed(2)} MB`,
'',
'### Nodes by Kind:',
];
for (const [kind, count] of Object.entries(stats.nodesByKind)) {
if ((count as number) > 0) {
lines.push(`- ${kind}: ${count}`);
}
}
lines.push('', '### Languages:');
for (const [lang, count] of Object.entries(stats.filesByLanguage)) {
if ((count as number) > 0) {
lines.push(`- ${lang}: ${count}`);
}
}
return this.textResult(lines.join('\n'));
}
/**
* Handle codegraph_files - get project file structure from the index
*/
private async handleFiles(args: Record<string, unknown>): Promise<ToolResult> {
const cg = this.getCodeGraph(args.projectPath as string | undefined);
const pathFilter = args.path as string | undefined;
const pattern = args.pattern as string | undefined;
const format = (args.format as 'tree' | 'flat' | 'grouped') || 'tree';
const includeMetadata = args.includeMetadata !== false;
const maxDepth = args.maxDepth != null ? clamp(args.maxDepth as number, 1, 20) : undefined;
// Get all files from the index
const allFiles = cg.getFiles();
if (allFiles.length === 0) {
return this.textResult('No files indexed. Run `codegraph index` first.');
}
// Filter by path prefix
let files = pathFilter
? allFiles.filter(f => f.path.startsWith(pathFilter) || f.path.startsWith('./' + pathFilter))
: allFiles;
// Filter by glob pattern
if (pattern) {
const regex = this.globToRegex(pattern);
files = files.filter(f => regex.test(f.path));
}
if (files.length === 0) {
return this.textResult(`No files found matching the criteria.`);
}
// Format output
let output: string;
switch (format) {
case 'flat':
output = this.formatFilesFlat(files, includeMetadata);
break;
case 'grouped':
output = this.formatFilesGrouped(files, includeMetadata);
break;
case 'tree':
default:
output = this.formatFilesTree(files, includeMetadata, maxDepth);
break;
}
return this.textResult(this.truncateOutput(output));
}
/**
* Convert glob pattern to regex
*/
private globToRegex(pattern: string): RegExp {
const escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special regex chars except * and ?
.replace(/\*\*/g, '{{GLOBSTAR}}') // Temp placeholder for **
.replace(/\*/g, '[^/]*') // * matches anything except /
.replace(/\?/g, '[^/]') // ? matches single char except /
.replace(/\{\{GLOBSTAR\}\}/g, '.*'); // ** matches anything including /
return new RegExp(escaped);
}
/**
* Format files as a flat list
*/
private formatFilesFlat(files: { path: string; language: string; nodeCount: number }[], includeMetadata: boolean): string {
const lines: string[] = [`## Files (${files.length})`, ''];
for (const file of files.sort((a, b) => a.path.localeCompare(b.path))) {
if (includeMetadata) {
lines.push(`- ${file.path} (${file.language}, ${file.nodeCount} symbols)`);
} else {
lines.push(`- ${file.path}`);
}
}
return lines.join('\n');
}
/**
* Format files grouped by language
*/
private formatFilesGrouped(files: { path: string; language: string; nodeCount: number }[], includeMetadata: boolean): string {
const byLang = new Map<string, typeof files>();
for (const file of files) {
const existing = byLang.get(file.language) || [];
existing.push(file);
byLang.set(file.language, existing);
}
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})`);
for (const file of langFiles.sort((a, b) => a.path.localeCompare(b.path))) {
if (includeMetadata) {
lines.push(`- ${file.path} (${file.nodeCount} symbols)`);
} else {
lines.push(`- ${file.path}`);
}
}
lines.push('');
}
return lines.join('\n');
}
/**
* Format files as a tree structure
*/
private formatFilesTree(
files: { path: string; language: string; nodeCount: number }[],
includeMetadata: boolean,
maxDepth?: number
): string {
// Build tree structure
interface TreeNode {
name: string;
children: Map<string, TreeNode>;
file?: { language: string; nodeCount: number };
}
const root: TreeNode = { name: '', children: new Map() };
for (const file of files) {
const parts = file.path.split('/');
let current = root;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (!part) continue;
if (!current.children.has(part)) {
current.children.set(part, { name: part, children: new Map() });
}
current = current.children.get(part)!;
// If this is the last part, it's a file
if (i === parts.length - 1) {
current.file = { language: file.language, nodeCount: file.nodeCount };
}
}
}
// Render tree
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;
const connector = isLast ? '└── ' : '├── ';
const childPrefix = isLast ? ' ' : '│ ';
if (node.name) {
let line = prefix + connector + node.name;
if (node.file && includeMetadata) {
line += ` (${node.file.language}, ${node.file.nodeCount} symbols)`;
}
lines.push(line);
}
const children = [...node.children.values()];
// Sort: directories first, then files, both alphabetically
children.sort((a, b) => {
const aIsDir = a.children.size > 0 && !a.file;
const bIsDir = b.children.size > 0 && !b.file;
if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
return a.name.localeCompare(b.name);
});
for (let i = 0; i < children.length; i++) {
const child = children[i]!;
const nextPrefix = node.name ? prefix + childPrefix : prefix;
renderNode(child, nextPrefix, i === children.length - 1, depth + 1);
}
};
renderNode(root, '', true, 0);
return lines.join('\n');
}
// =========================================================================
// Symbol resolution helpers
// =========================================================================
/**
* Find a symbol by name, handling disambiguation when multiple matches exist.
* Returns the best match and a note about alternatives if any.
*/
/**
* Check if a node matches a symbol query, supporting both simple names and
* qualified "Parent.child" notation (e.g., "Session.request" matches a method
* named "request" inside a class named "Session").
*/
private matchesSymbol(node: Node, symbol: string): boolean {
// Simple name match
if (node.name === symbol) return true;
// File basename match (e.g., "product-card" matches "product-card.liquid")
if (node.kind === 'file' && node.name.replace(/\.[^.]+$/, '') === symbol) return true;
// Qualified name match: "Parent.child" → look for "::Parent::child" in qualified_name
if (symbol.includes('.')) {
const parts = symbol.split('.');
const qualifiedSuffix = parts.join('::');
if (node.qualifiedName.includes(qualifiedSuffix)) return true;
}
return false;
}
private findSymbol(cg: CodeGraph, symbol: string): { node: Node; note: string } | null {
// Use higher limit for qualified lookups (e.g., "Session.request") since the
// target may rank lower in FTS when there are many partial matches
const limit = symbol.includes('.') ? 50 : 10;
const results = cg.searchNodes(symbol, { limit });
if (results.length === 0 || !results[0]) {
return null;
}
const exactMatches = results.filter(r => this.matchesSymbol(r.node, symbol));
if (exactMatches.length === 1) {
return { node: exactMatches[0]!.node, note: '' };
}
if (exactMatches.length > 1) {
// Multiple exact matches - pick first, note the others
const picked = exactMatches[0]!.node;
const others = exactMatches.slice(1).map(r =>
`${r.node.name} (${r.node.kind}) at ${r.node.filePath}:${r.node.startLine}`
);
const note = `\n\n> **Note:** ${exactMatches.length} symbols named "${symbol}". Showing results for \`${picked.filePath}:${picked.startLine}\`. Others: ${others.join(', ')}`;
return { node: picked, note };
}
// No exact match, use best fuzzy match
return { node: results[0]!.node, note: '' };
}
/**
* Find ALL symbols matching a name. Used by callers/callees/impact to aggregate
* results across all matching symbols (e.g., multiple classes with an `execute` method).
*/
private findAllSymbols(cg: CodeGraph, symbol: string): { nodes: Node[]; note: string } {
const results = cg.searchNodes(symbol, { limit: 50 });
if (results.length === 0) {
return { nodes: [], note: '' };
}
const exactMatches = results.filter(r => this.matchesSymbol(r.node, symbol));
if (exactMatches.length <= 1) {
const node = exactMatches[0]?.node ?? results[0]!.node;
return { nodes: [node], note: '' };
}
const locations = exactMatches.map(r =>
`${r.node.kind} at ${r.node.filePath}:${r.node.startLine}`
);
const note = `\n\n> **Note:** Aggregated results across ${exactMatches.length} symbols named "${symbol}": ${locations.join(', ')}`;
return { nodes: exactMatches.map(r => r.node), note };
}
/**
* Truncate output if it exceeds the maximum length
*/
private truncateOutput(text: string): string {
if (text.length <= MAX_OUTPUT_LENGTH) return text;
const truncated = text.slice(0, MAX_OUTPUT_LENGTH);
const lastNewline = truncated.lastIndexOf('\n');
const cutPoint = lastNewline > MAX_OUTPUT_LENGTH * 0.8 ? lastNewline : MAX_OUTPUT_LENGTH;
return truncated.slice(0, cutPoint) + '\n\n... (output truncated)';
}
// =========================================================================
// Formatting helpers (compact by default to reduce context usage)
// =========================================================================
private formatSearchResults(results: SearchResult[]): string {
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.filePath}${location}`);
if (node.signature) lines.push(`\`${node.signature}\``);
lines.push('');
}
return lines.join('\n');
}
private formatNodeList(nodes: Node[], title: string): string {
const lines: string[] = [`## ${title} (${nodes.length} found)`, ''];
for (const node of nodes) {
const location = node.startLine ? `:${node.startLine}` : '';
// Compact: just name, kind, location
lines.push(`- ${node.name} (${node.kind}) - ${node.filePath}${location}`);
}
return lines.join('\n');
}
private formatImpact(symbol: string, impact: Subgraph): string {
const nodeCount = impact.nodes.size;
// Compact format: just list affected symbols grouped by file
const lines: string[] = [
`## Impact: "${symbol}" affects ${nodeCount} symbols`,
'',
];
// Group by file
const byFile = new Map<string, Node[]>();
for (const node of impact.nodes.values()) {
const existing = byFile.get(node.filePath) || [];
existing.push(node);
byFile.set(node.filePath, existing);
}
for (const [file, nodes] of byFile) {
lines.push(`**${file}:**`);
// Compact: inline list
const nodeList = nodes.map(n => `${n.name}:${n.startLine}`).join(', ');
lines.push(nodeList);
lines.push('');
}
return lines.join('\n');
}
private formatNodeDetails(node: Node, code: string | null): string {
const location = node.startLine ? `:${node.startLine}` : '';
const lines: string[] = [
`## ${node.name} (${node.kind})`,
'',
`**Location:** ${node.filePath}${location}`,
];
if (node.signature) {
lines.push(`**Signature:** \`${node.signature}\``);
}
// Only include docstring if it's short and useful
if (node.docstring && node.docstring.length < 200) {
lines.push('', node.docstring);
}
if (code) {
lines.push('', '```' + node.language, code, '```');
}
return lines.join('\n');
}
private formatTaskContext(context: TaskContext): string {
return context.summary || 'No context found';
}
private textResult(text: string): ToolResult {
return {
content: [{ type: 'text', text }],
};
}
private errorResult(message: string): ToolResult {
return {
content: [{ type: 'text', text: `Error: ${message}` }],
isError: true,
};
}
}