- Add findSymbol() helper that prefers exact name matches and notes alternatives when multiple symbols share the same name - Add output truncation (15K char cap) to prevent context window bloat - Apply to callers, callees, impact, node, search, and files tools Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
900 lines
29 KiB
TypeScript
900 lines
29 KiB
TypeScript
/**
|
|
* MCP Tool Definitions
|
|
*
|
|
* Defines the tools exposed by the CodeGraph MCP server.
|
|
*/
|
|
|
|
import CodeGraph, { findNearestCodeGraphRoot } from '../index';
|
|
import type { Node, SearchResult, Subgraph, TaskContext, NodeKind } from '../types';
|
|
import { createHash } from 'crypto';
|
|
import { writeFileSync } from 'fs';
|
|
import { clamp } from '../utils';
|
|
import { tmpdir } from 'os';
|
|
import { join } from 'path';
|
|
|
|
/**
|
|
* 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_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) {}
|
|
|
|
/**
|
|
* 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) {
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* 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_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) {
|
|
try { const { captureException } = require('../sentry'); captureException(err, { tool: toolName }); } catch {}
|
|
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 cg = this.getCodeGraph(args.projectPath as string | undefined);
|
|
const query = args.query as string;
|
|
const kind = args.kind as string | undefined;
|
|
const limit = clamp((args.limit as number) || 10, 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> {
|
|
// 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 task = args.task as string;
|
|
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 cg = this.getCodeGraph(args.projectPath as string | undefined);
|
|
const symbol = args.symbol as string;
|
|
const limit = clamp((args.limit as number) || 20, 1, 100);
|
|
|
|
const match = this.findSymbol(cg, symbol);
|
|
if (!match) {
|
|
return this.textResult(`Symbol "${symbol}" not found in the codebase`);
|
|
}
|
|
|
|
const callers = cg.getCallers(match.node.id);
|
|
|
|
if (callers.length === 0) {
|
|
return this.textResult(`No callers found for "${symbol}"${match.note}`);
|
|
}
|
|
|
|
const callerNodes = callers.slice(0, limit).map(c => c.node);
|
|
const formatted = this.formatNodeList(callerNodes, `Callers of ${symbol}`) + match.note;
|
|
return this.textResult(this.truncateOutput(formatted));
|
|
}
|
|
|
|
/**
|
|
* Handle codegraph_callees
|
|
*/
|
|
private async handleCallees(args: Record<string, unknown>): Promise<ToolResult> {
|
|
const cg = this.getCodeGraph(args.projectPath as string | undefined);
|
|
const symbol = args.symbol as string;
|
|
const limit = clamp((args.limit as number) || 20, 1, 100);
|
|
|
|
const match = this.findSymbol(cg, symbol);
|
|
if (!match) {
|
|
return this.textResult(`Symbol "${symbol}" not found in the codebase`);
|
|
}
|
|
|
|
const callees = cg.getCallees(match.node.id);
|
|
|
|
if (callees.length === 0) {
|
|
return this.textResult(`No callees found for "${symbol}"${match.note}`);
|
|
}
|
|
|
|
const calleeNodes = callees.slice(0, limit).map(c => c.node);
|
|
const formatted = this.formatNodeList(calleeNodes, `Callees of ${symbol}`) + match.note;
|
|
return this.textResult(this.truncateOutput(formatted));
|
|
}
|
|
|
|
/**
|
|
* Handle codegraph_impact
|
|
*/
|
|
private async handleImpact(args: Record<string, unknown>): Promise<ToolResult> {
|
|
const cg = this.getCodeGraph(args.projectPath as string | undefined);
|
|
const symbol = args.symbol as string;
|
|
const depth = clamp((args.depth as number) || 2, 1, 10);
|
|
|
|
const match = this.findSymbol(cg, symbol);
|
|
if (!match) {
|
|
return this.textResult(`Symbol "${symbol}" not found in the codebase`);
|
|
}
|
|
|
|
const impact = cg.getImpactRadius(match.node.id, depth);
|
|
|
|
const formatted = this.formatImpact(symbol, impact) + match.note;
|
|
return this.textResult(this.truncateOutput(formatted));
|
|
}
|
|
|
|
/**
|
|
* Handle codegraph_node
|
|
*/
|
|
private async handleNode(args: Record<string, unknown>): Promise<ToolResult> {
|
|
const cg = this.getCodeGraph(args.projectPath as string | undefined);
|
|
const symbol = args.symbol as string;
|
|
// 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.
|
|
*/
|
|
private findSymbol(cg: CodeGraph, symbol: string): { node: Node; note: string } | null {
|
|
const results = cg.searchNodes(symbol, { limit: 10 });
|
|
|
|
if (results.length === 0 || !results[0]) {
|
|
return null;
|
|
}
|
|
|
|
// If only one result, or first is an exact name match, use it directly
|
|
const exactMatches = results.filter(r => r.node.name === 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: '' };
|
|
}
|
|
|
|
/**
|
|
* Maximum output length to prevent context bloat (characters)
|
|
*/
|
|
private readonly MAX_OUTPUT_LENGTH = 15000;
|
|
|
|
/**
|
|
* Truncate output if it exceeds the maximum length
|
|
*/
|
|
private truncateOutput(text: string): string {
|
|
if (text.length <= this.MAX_OUTPUT_LENGTH) return text;
|
|
const truncated = text.slice(0, this.MAX_OUTPUT_LENGTH);
|
|
const lastNewline = truncated.lastIndexOf('\n');
|
|
const cutPoint = lastNewline > this.MAX_OUTPUT_LENGTH * 0.8 ? lastNewline : this.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,
|
|
};
|
|
}
|
|
}
|