Skip to content

Commit b1c52e4

Browse files
committed
feat(mcp): add dev_refs tool for call graph queries
Create RefsAdapter that provides the dev_refs MCP tool: - Query callees (what a function calls) - Query callers (what calls a function) - Query both directions at once - Format output as readable markdown - Find best match for function name via semantic search - Compute callers by reverse lookup through indexed callees Also adds: - startTimer() utility for measuring operation duration - Uses estimateTokensForText for accurate token counting Registered in MCP server alongside existing adapters. Part of #80
1 parent 5722c3c commit b1c52e4

File tree

5 files changed

+400
-2
lines changed

5 files changed

+400
-2
lines changed

packages/mcp-server/bin/dev-agent-mcp.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
GitHubAdapter,
2323
HealthAdapter,
2424
PlanAdapter,
25+
RefsAdapter,
2526
SearchAdapter,
2627
StatusAdapter,
2728
} from '../src/adapters/built-in';
@@ -180,6 +181,11 @@ async function main() {
180181
githubStatePath: filePaths.githubState,
181182
});
182183

184+
const refsAdapter = new RefsAdapter({
185+
repositoryIndexer: indexer,
186+
defaultLimit: 20,
187+
});
188+
183189
// Create MCP server with coordinator
184190
const server = new MCPServer({
185191
serverInfo: {
@@ -198,6 +204,7 @@ async function main() {
198204
exploreAdapter,
199205
githubAdapter,
200206
healthAdapter,
207+
refsAdapter,
201208
],
202209
coordinator,
203210
});

packages/mcp-server/src/adapters/built-in/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@ export { ExploreAdapter, type ExploreAdapterConfig } from './explore-adapter';
77
export { GitHubAdapter, type GitHubAdapterConfig } from './github-adapter';
88
export { HealthAdapter, type HealthCheckConfig } from './health-adapter';
99
export { PlanAdapter, type PlanAdapterConfig } from './plan-adapter';
10+
export { RefsAdapter, type RefsAdapterConfig } from './refs-adapter';
1011
export { SearchAdapter, type SearchAdapterConfig } from './search-adapter';
1112
export { StatusAdapter, type StatusAdapterConfig } from './status-adapter';
Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
/**
2+
* Refs Adapter
3+
* Provides call graph queries via the dev_refs tool
4+
*/
5+
6+
import type { CalleeInfo, RepositoryIndexer, SearchResult } from '@lytics/dev-agent-core';
7+
import { estimateTokensForText, startTimer } from '../../formatters/utils';
8+
import { ToolAdapter } from '../tool-adapter';
9+
import type { AdapterContext, ToolDefinition, ToolExecutionContext, ToolResult } from '../types';
10+
11+
/**
12+
* Direction of relationship query
13+
*/
14+
export type RefDirection = 'callees' | 'callers' | 'both';
15+
16+
/**
17+
* Refs adapter configuration
18+
*/
19+
export interface RefsAdapterConfig {
20+
/**
21+
* Repository indexer instance
22+
*/
23+
repositoryIndexer: RepositoryIndexer;
24+
25+
/**
26+
* Default result limit
27+
*/
28+
defaultLimit?: number;
29+
}
30+
31+
/**
32+
* Reference result for output
33+
*/
34+
interface RefResult {
35+
name: string;
36+
file?: string;
37+
line: number;
38+
type?: string;
39+
snippet?: string;
40+
}
41+
42+
/**
43+
* Refs Adapter
44+
* Implements the dev_refs tool for querying call relationships
45+
*/
46+
export class RefsAdapter extends ToolAdapter {
47+
readonly metadata = {
48+
name: 'refs-adapter',
49+
version: '1.0.0',
50+
description: 'Call graph relationship adapter',
51+
author: 'Dev-Agent Team',
52+
};
53+
54+
private indexer: RepositoryIndexer;
55+
private config: Required<RefsAdapterConfig>;
56+
57+
constructor(config: RefsAdapterConfig) {
58+
super();
59+
this.indexer = config.repositoryIndexer;
60+
this.config = {
61+
repositoryIndexer: config.repositoryIndexer,
62+
defaultLimit: config.defaultLimit ?? 20,
63+
};
64+
}
65+
66+
async initialize(context: AdapterContext): Promise<void> {
67+
context.logger.info('RefsAdapter initialized', {
68+
defaultLimit: this.config.defaultLimit,
69+
});
70+
}
71+
72+
getToolDefinition(): ToolDefinition {
73+
return {
74+
name: 'dev_refs',
75+
description:
76+
'Find call relationships for a function or method. Shows what calls it (callers) and what it calls (callees).',
77+
inputSchema: {
78+
type: 'object',
79+
properties: {
80+
name: {
81+
type: 'string',
82+
description:
83+
'Name of the function or method to query (e.g., "createPlan", "SearchAdapter.execute")',
84+
},
85+
direction: {
86+
type: 'string',
87+
enum: ['callees', 'callers', 'both'],
88+
description:
89+
'Direction of query: "callees" (what this calls), "callers" (what calls this), or "both" (default)',
90+
default: 'both',
91+
},
92+
limit: {
93+
type: 'number',
94+
description: `Maximum number of results per direction (default: ${this.config.defaultLimit})`,
95+
minimum: 1,
96+
maximum: 50,
97+
default: this.config.defaultLimit,
98+
},
99+
},
100+
required: ['name'],
101+
},
102+
};
103+
}
104+
105+
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<ToolResult> {
106+
const {
107+
name,
108+
direction = 'both',
109+
limit = this.config.defaultLimit,
110+
} = args as {
111+
name: string;
112+
direction?: RefDirection;
113+
limit?: number;
114+
};
115+
116+
// Validate name
117+
if (typeof name !== 'string' || name.trim().length === 0) {
118+
return {
119+
success: false,
120+
error: {
121+
code: 'INVALID_NAME',
122+
message: 'Name must be a non-empty string',
123+
},
124+
};
125+
}
126+
127+
// Validate direction
128+
if (!['callees', 'callers', 'both'].includes(direction)) {
129+
return {
130+
success: false,
131+
error: {
132+
code: 'INVALID_DIRECTION',
133+
message: 'Direction must be "callees", "callers", or "both"',
134+
},
135+
};
136+
}
137+
138+
// Validate limit
139+
if (typeof limit !== 'number' || limit < 1 || limit > 50) {
140+
return {
141+
success: false,
142+
error: {
143+
code: 'INVALID_LIMIT',
144+
message: 'Limit must be a number between 1 and 50',
145+
},
146+
};
147+
}
148+
149+
try {
150+
const timer = startTimer();
151+
context.logger.debug('Executing refs query', { name, direction, limit });
152+
153+
// First, find the target component
154+
const searchResults = await this.indexer.search(name, { limit: 10 });
155+
const target = this.findBestMatch(searchResults, name);
156+
157+
if (!target) {
158+
return {
159+
success: false,
160+
error: {
161+
code: 'NOT_FOUND',
162+
message: `Could not find function or method named "${name}"`,
163+
},
164+
};
165+
}
166+
167+
const result: {
168+
target: {
169+
name: string;
170+
file: string;
171+
line: number;
172+
type: string;
173+
};
174+
callees?: RefResult[];
175+
callers?: RefResult[];
176+
} = {
177+
target: {
178+
name: target.metadata.name || name,
179+
file: target.metadata.path || '',
180+
line: target.metadata.startLine || 0,
181+
type: (target.metadata.type as string) || 'unknown',
182+
},
183+
};
184+
185+
// Get callees if requested
186+
if (direction === 'callees' || direction === 'both') {
187+
result.callees = this.getCallees(target, limit);
188+
}
189+
190+
// Get callers if requested
191+
if (direction === 'callers' || direction === 'both') {
192+
result.callers = await this.getCallers(target, limit);
193+
}
194+
195+
const content = this.formatOutput(result, direction);
196+
const duration_ms = timer.elapsed();
197+
198+
context.logger.info('Refs query completed', {
199+
name,
200+
direction,
201+
calleesCount: result.callees?.length ?? 0,
202+
callersCount: result.callers?.length ?? 0,
203+
duration_ms,
204+
});
205+
206+
const tokens = estimateTokensForText(content);
207+
208+
return {
209+
success: true,
210+
data: {
211+
name,
212+
direction,
213+
content,
214+
...result,
215+
},
216+
metadata: {
217+
tokens,
218+
duration_ms,
219+
timestamp: new Date().toISOString(),
220+
cached: false,
221+
},
222+
};
223+
} catch (error) {
224+
context.logger.error('Refs query failed', { error });
225+
return {
226+
success: false,
227+
error: {
228+
code: 'REFS_FAILED',
229+
message: error instanceof Error ? error.message : 'Unknown error',
230+
details: error,
231+
},
232+
};
233+
}
234+
}
235+
236+
/**
237+
* Find the best matching result for a name query
238+
*/
239+
private findBestMatch(results: SearchResult[], name: string): SearchResult | null {
240+
if (results.length === 0) return null;
241+
242+
// Exact name match takes priority
243+
const exactMatch = results.find(
244+
(r) => r.metadata.name === name || r.metadata.name?.endsWith(`.${name}`)
245+
);
246+
if (exactMatch) return exactMatch;
247+
248+
// Otherwise return the highest scoring result
249+
return results[0];
250+
}
251+
252+
/**
253+
* Get callees from the target's metadata
254+
*/
255+
private getCallees(target: SearchResult, limit: number): RefResult[] {
256+
const callees = target.metadata.callees as CalleeInfo[] | undefined;
257+
if (!callees || callees.length === 0) return [];
258+
259+
return callees.slice(0, limit).map((c) => ({
260+
name: c.name,
261+
file: c.file,
262+
line: c.line,
263+
}));
264+
}
265+
266+
/**
267+
* Find callers by searching all indexed components for callees that reference the target
268+
*/
269+
private async getCallers(target: SearchResult, limit: number): Promise<RefResult[]> {
270+
const targetName = target.metadata.name;
271+
if (!targetName) return [];
272+
273+
// Search for components that might call this target
274+
// We search broadly and then filter by callees
275+
const candidates = await this.indexer.search(targetName, { limit: 100 });
276+
277+
const callers: RefResult[] = [];
278+
279+
for (const candidate of candidates) {
280+
// Skip the target itself
281+
if (candidate.id === target.id) continue;
282+
283+
const callees = candidate.metadata.callees as CalleeInfo[] | undefined;
284+
if (!callees) continue;
285+
286+
// Check if any callee matches our target
287+
const callsTarget = callees.some(
288+
(c) =>
289+
c.name === targetName ||
290+
c.name.endsWith(`.${targetName}`) ||
291+
targetName.endsWith(`.${c.name}`)
292+
);
293+
294+
if (callsTarget) {
295+
callers.push({
296+
name: candidate.metadata.name || 'unknown',
297+
file: candidate.metadata.path,
298+
line: candidate.metadata.startLine || 0,
299+
type: candidate.metadata.type as string,
300+
snippet: candidate.metadata.signature as string | undefined,
301+
});
302+
303+
if (callers.length >= limit) break;
304+
}
305+
}
306+
307+
return callers;
308+
}
309+
310+
/**
311+
* Format the output as readable text
312+
*/
313+
private formatOutput(
314+
result: {
315+
target: { name: string; file: string; line: number; type: string };
316+
callees?: RefResult[];
317+
callers?: RefResult[];
318+
},
319+
direction: RefDirection
320+
): string {
321+
const lines: string[] = [];
322+
323+
lines.push(`# References for ${result.target.name}`);
324+
lines.push(`**Location:** ${result.target.file}:${result.target.line}`);
325+
lines.push(`**Type:** ${result.target.type}`);
326+
lines.push('');
327+
328+
if (direction === 'callees' || direction === 'both') {
329+
lines.push('## Callees (what this calls)');
330+
if (result.callees && result.callees.length > 0) {
331+
for (const callee of result.callees) {
332+
const location = callee.file ? `${callee.file}:${callee.line}` : `line ${callee.line}`;
333+
lines.push(`- \`${callee.name}\` at ${location}`);
334+
}
335+
} else {
336+
lines.push('*No callees found*');
337+
}
338+
lines.push('');
339+
}
340+
341+
if (direction === 'callers' || direction === 'both') {
342+
lines.push('## Callers (what calls this)');
343+
if (result.callers && result.callers.length > 0) {
344+
for (const caller of result.callers) {
345+
const location = caller.file ? `${caller.file}:${caller.line}` : `line ${caller.line}`;
346+
lines.push(`- \`${caller.name}\` (${caller.type}) at ${location}`);
347+
}
348+
} else {
349+
lines.push('*No callers found in indexed code*');
350+
}
351+
lines.push('');
352+
}
353+
354+
return lines.join('\n');
355+
}
356+
357+
estimateTokens(args: Record<string, unknown>): number {
358+
const { limit = this.config.defaultLimit, direction = 'both' } = args;
359+
const multiplier = direction === 'both' ? 2 : 1;
360+
return (limit as number) * 15 * multiplier + 50;
361+
}
362+
}

0 commit comments

Comments
 (0)