Files
agenticCode/ac-code-server/src/main/java/com/agenticcode/codeserver/mcp/McpQueryTools.java
Ingo Schnabel 2cf16274c3 Bug fixes
2026-07-28 09:30:03 +02:00

771 lines
51 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.agenticcode.codeserver.mcp;
import com.agenticcode.codeserver.service.*;
import com.agenticcode.neo4jstore.graph.*;
import com.agenticcode.parsercore.ast.model.NodeType;
import io.quarkiverse.mcp.server.Tool;
import io.quarkiverse.mcp.server.ToolArg;
import io.quarkiverse.mcp.server.ToolResponse;
import io.smallrye.common.annotation.Blocking;
import io.smallrye.mutiny.Uni;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jspecify.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Path;
import java.util.*;
import java.util.function.Function;
import java.util.function.Supplier;
/**
* MCP tools mirroring the read endpoints of {@code AnalysisResource}. Each tool delegates to the
* same {@link GraphRepository} query the REST resource uses, so the graph is queried identically
* whether an agent goes through REST or MCP.
*
* <p>All tools are {@link Blocking} (they drive the blocking Neo4j driver, exactly like the
* {@code @Blocking} REST resource) and return a {@link ToolResponse} whose text content is the same
* concise JSON the REST API returns. Every module/variable/data-structure tool takes the
* {@code project} as its first argument and returns a {@code PROJECT_NOT_FOUND} tool error if it
* does not exist.
*/
@ApplicationScoped
@McpLogged
public class McpQueryTools {
private static final int DEFAULT_PAGE_LIMIT = 50;
private final GraphRepository graphRepository;
private final McpSupport support;
private final ProjectRootResolver rootResolver;
private final DeepIngestCoordinator deepIngestCoordinator;
private final SourceSnippetService sourceSnippetService;
private final SourceSearchService sourceSearchService;
private final VersionInfo versionInfo;
private final int defaultCallTreeDepth;
private final int maxCallTreeDepth;
/**
* Item 67: raw hops allowed per module hop. A pure safety cap — the query prunes its traversal to the
* modules within the requested depth, so this only stops a pathological internal chain. Measured on
* upms: a single module hop reached 9 internal hops, and the quantifier costs nothing (identical
* results and runtime at 8, 20 and 40), so it is sized well above the observed maximum. Too tight a
* value silently truncates; exhausting it sets truncated=true on the response.
*/
private final int callTreeInternalBudget;
// -------------------------------------------------------------------------
// Projects & module discovery
// -------------------------------------------------------------------------
public McpQueryTools(GraphRepository graphRepository, McpSupport support,
ProjectRootResolver rootResolver, DeepIngestCoordinator deepIngestCoordinator,
SourceSnippetService sourceSnippetService, SourceSearchService sourceSearchService,
VersionInfo versionInfo,
@ConfigProperty(name = "agenticcode.call-tree.default-depth", defaultValue = "3") int defaultCallTreeDepth,
@ConfigProperty(name = "agenticcode.call-tree.max-depth", defaultValue = "10") int maxCallTreeDepth,
@ConfigProperty(name = "agenticcode.call-tree.internal-budget", defaultValue = "20") int callTreeInternalBudget) {
this.graphRepository = graphRepository;
this.support = support;
this.rootResolver = rootResolver;
this.deepIngestCoordinator = deepIngestCoordinator;
this.sourceSnippetService = sourceSnippetService;
this.sourceSearchService = sourceSearchService;
this.versionInfo = versionInfo;
this.defaultCallTreeDepth = defaultCallTreeDepth;
this.maxCallTreeDepth = maxCallTreeDepth;
this.callTreeInternalBudget = callTreeInternalBudget;
}
@Tool(name = "version", description = "AgenticCode server name and version.")
public ToolResponse version() {
return support.ok(versionInfo);
}
private static int effectiveLimit(@Nullable Integer limit) {
return limit != null ? Math.max(limit, 0) : DEFAULT_PAGE_LIMIT;
}
private static int effectiveOffset(@Nullable Integer offset) {
return offset != null ? Math.max(offset, 0) : 0;
}
/**
* Item 103: see {@code AnalysisResource.uncappedLimit} — absent {@code limit} means "all rows" for
* db/workfile accesses, because their bare-array response cannot signal that it was cut.
*/
private static int uncappedLimit(@Nullable Integer limit) {
return limit != null ? Math.max(limit, 0) : Integer.MAX_VALUE;
}
private static DeepIngestRequired deepIngestHint(String module, ModuleIngestState state) {
String status = state.exists() ? "NOT_DEEPLY_INGESTED" : "NOT_INGESTED";
String detail = state.exists()
? "Module '" + module + "' has only its call graph ingested; field-level dataflow requires a deep ingest."
: "Module '" + module + "' is not ingested. Ingest the call graph and/or deep-ingest it.";
return new DeepIngestRequired(status, module, detail, "ingest_module");
}
@Tool(name = "list_projects", description = "List all ingested projects (name, description, root, excludeDirs, language, generatedDir, userExitDir).")
@Blocking
public Uni<ToolResponse> listProjects() {
return graphRepository.listProjects().map(support::ok);
}
@Tool(name = "list_modules", description = "List MODULE nodes (name, sourceFile, moduleKind, loc, sloc) in a project, optionally filtered by source file, moduleKind (CLASS/INTERFACE/PROGRAM/SUBPROGRAM), and/or direct EXTENDS base class name. loc = physical lines, sloc = source (non-blank, non-comment) lines; null for modules ingested before this existed.")
@Blocking
public Uni<ToolResponse> listModules(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Optional source file path to filter by", required = false) @Nullable String sourceFile,
@ToolArg(description = "Optional module sub-kind to filter by (CLASS/INTERFACE/PROGRAM/SUBPROGRAM)", required = false) @Nullable String moduleKind,
@ToolArg(description = "Optional base class name; lists only its direct EXTENDS subclasses", required = false) @Nullable String extendsName) {
return withProject(project, () -> graphRepository.listModules(project, sourceFile, moduleKind, extendsName).map(support::ok));
}
@Tool(name = "search_source", description = "Regex search over module SOURCE TEXT (item 54), read from disk. Returns matches as {module, sourceFile, lineNo, line}, with a 'truncated' flag when the match limit is hit. Case-insensitive by default (legacy Natural is case-insensitive). Use this to grep for code patterns (statements, table names, literals) across a project — complementary to search_identifier (which matches declared names).")
@Blocking
public Uni<ToolResponse> searchSource(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Java regular expression to search for") String regex,
@ToolArg(description = "Max matches to return (default 200)", required = false) @Nullable Integer limit,
@ToolArg(description = "Case-insensitive match (default true)", required = false) @Nullable Boolean ignoreCase) {
if (regex == null || regex.isBlank()) {
return Uni.createFrom().item(support.error("MISSING_REGEX", "'regex' is required"));
}
boolean caseInsensitive = ignoreCase == null || ignoreCase;
java.util.regex.Pattern pattern;
try {
pattern = java.util.regex.Pattern.compile(regex, caseInsensitive ? java.util.regex.Pattern.CASE_INSENSITIVE : 0);
} catch (java.util.regex.PatternSyntaxException e) {
return Uni.createFrom().item(support.error("INVALID_REGEX", "Invalid regex: " + e.getMessage()));
}
int effectiveLimit = limit != null && limit > 0 ? limit : 200;
String root;
switch (rootResolver.resolve(project)) {
case ProjectRootResolver.Resolved r -> root = r.project().root();
case ProjectRootResolver.Failed failed -> {
return Uni.createFrom().item(support.error(failed.code(), failed.message()));
}
}
List<ModuleInfo> modules = graphRepository.listModules(project, null, null, null).await().indefinitely();
return Uni.createFrom().item(support.ok(sourceSearchService.search(root, modules, pattern, effectiveLimit)));
}
@Tool(name = "project_loc", description = "Per-language LoC/SLoC rollup for a project: a breakdown by language (fileCount, loc, sloc) plus the project-wide total. loc = physical lines, sloc = source (non-blank, non-comment) lines computed per language. Each source file is counted once. For a project with a generated/user_exit split (item 47), each row also carries userExitLoc/userExitSloc and generatedExclusiveLoc/generatedExclusiveSloc (= total − user_exit); zero when unconfigured. Optionally narrow to one language ('natural'/'java') and/or one source file.")
@Blocking
public Uni<ToolResponse> projectLoc(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Optional language to filter by ('natural'/'java')", required = false) @Nullable String language,
@ToolArg(description = "Optional source file path to filter by", required = false) @Nullable String sourceFile) {
return withProject(project, () -> graphRepository.projectLoc(project, language, sourceFile).map(support::ok));
}
@Tool(name = "module_digest", description = "Small triage bundle for a module: description, function count, caller/callee names, DB table names, data-structure names + field counts.")
@Blocking
public Uni<ToolResponse> moduleDigest(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module name (not a file path)") String name) {
return withProject(project, () -> graphRepository.moduleDigest(project, name).map(support::ok));
}
@Tool(name = "module_context", description = "Full context bundle for a module. Heavy sub-arrays (variableAccesses, large sqlStatements) are summarized unless requested via 'include'.")
@Blocking
public Uni<ToolResponse> moduleContext(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module name") String name,
@ToolArg(description = "Comma-separated sections to fully expand, e.g. 'variableAccesses,sqlStatements'", required = false) @Nullable String include,
@ToolArg(description = "Max items for expanded sub-arrays (0 = unbounded)", required = false) @Nullable Integer limit,
@ToolArg(description = "Offset into expanded sub-arrays", required = false) @Nullable Integer offset) {
int effLimit = limit != null ? Math.max(limit, 0) : 0;
int effOffset = offset != null ? Math.max(offset, 0) : 0;
return withProject(project, () -> graphRepository.moduleContext(project, name, include, effLimit, effOffset).map(support::ok));
}
// -------------------------------------------------------------------------
// Call graph
// -------------------------------------------------------------------------
@Tool(name = "module_functions", description = "List functions (Natural subroutines / Java methods) declared in a module; set includeInherited to include inherited Java methods. 'kind' filters Java methods by modifier: abstract/final/overridable.")
@Blocking
public Uni<ToolResponse> moduleFunctions(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module name") String name,
@ToolArg(description = "Include inherited (Java) methods", required = false) @Nullable Boolean includeInherited,
@ToolArg(description = "Optional Java modifier filter: abstract/final/overridable", required = false) @Nullable String kind) {
boolean inherited = includeInherited != null && includeInherited;
return withProject(project, () -> graphRepository.moduleFunctions(project, name, inherited, kind).map(support::ok));
}
@Tool(name = "function_overrides", description = "Concrete subclass overrides of a base-class method (Java virtual/template-method dispatch). Pass 'function' for one hook method, or omit it to get overrides for every abstract method of the base class at once, grouped by hook method.")
@Blocking
public Uni<ToolResponse> functionOverrides(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module (base class) name") String name,
@ToolArg(description = "Base method name; omit for the bulk (all-abstract-methods) variant", required = false) @Nullable String function) {
return withProject(project, () -> function == null || function.isBlank()
? graphRepository.functionOverrides(project, name).map(support::ok)
: graphRepository.functionOverrides(project, name, function).map(support::ok));
}
@Tool(name = "function_callers", description = "FUNCTION-level callers of a subroutine/method: who PERFORMs (Natural) or calls (Java cross-class) the given 'function' in module 'name', with call-site line numbers. Finer-grained than module-level 'callers'.")
@Blocking
public Uni<ToolResponse> functionCallers(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module name that defines the subroutine/method") String name,
@ToolArg(description = "Subroutine/method name") String function) {
return withProject(project, () -> graphRepository.functionCallers(project, name, function).map(support::ok));
}
@Tool(name = "module_data_structures", description = "Data structures (Natural DEFINE DATA blocks / Java DTOs) referenced by a module.")
@Blocking
public Uni<ToolResponse> moduleDataStructures(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module name") String name) {
return withProject(project, () -> graphRepository.moduleDataStructures(project, name).map(support::ok));
}
@Tool(name = "module_payload", description = "XML wire payload contract of a Natural module: the tag/field/direction triples emitted via the ADD-XML-LINE idiom.")
@Blocking
public Uni<ToolResponse> modulePayload(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module name") String name) {
return withProject(project, () -> graphRepository.payload(project, name).map(support::ok));
}
@Tool(name = "module_dispatch_table", description = "Dispatch table for a module: dynamic-call lookup arrays and the module names their literals resolve to.")
@Blocking
public Uni<ToolResponse> moduleDispatchTable(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module name") String name) {
return withProject(project, () -> graphRepository.dispatchTable(project, name).map(support::ok));
}
// -------------------------------------------------------------------------
// Dynamic-CALLNAT manual overrides (item 82)
// -------------------------------------------------------------------------
@Tool(name = "list_unresolved_dynamic_calls", description = "List the unresolved dynamic CALLNAT <var> call sites in a project — the ones the auto-resolvers couldn't recover. Each row is {module, originFile, lineNo, variable}; (originFile, lineNo) is the key to pin a target with set_dynamic_call_override.")
@Blocking
public Uni<ToolResponse> listUnresolvedDynamicCalls(
@ToolArg(description = "Project name") String project) {
return withProject(project, () -> graphRepository.listUnresolvedDynamicCalls(project).map(support::ok));
}
@Tool(name = "list_dynamic_call_overrides", description = "List the manual dynamic-CALLNAT overrides in a project. Each carries its targets and an 'obsolete' flag (true once an auto-resolver has since resolved that call site).")
@Blocking
public Uni<ToolResponse> listDynamicCallOverrides(
@ToolArg(description = "Project name") String project) {
return withProject(project, () -> graphRepository.listDynamicCallOverrides(project).map(support::ok));
}
@Tool(name = "set_dynamic_call_override", description = "Pin an unresolvable dynamic CALLNAT call site (identified by originFile + lineNo) to one or more target modules. Multiple targets model deliberate branches. Applied immediately (no refresh) and persisted across refreshes. Rejected if any target is not a real module.")
@Blocking
public Uni<ToolResponse> setDynamicCallOverride(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "File the call site lives in (module's own file or an included copycode)") String originFile,
@ToolArg(description = "Line number of the CALLNAT within originFile") Integer lineNo,
@ToolArg(description = "Comma-separated target module name(s), e.g. 'YGEAGGN0' or 'A,B'") String targets,
@ToolArg(description = "Dispatch variable name, for reference (optional)", required = false) @Nullable String variable,
@ToolArg(description = "Free-text note (optional)", required = false) @Nullable String note) {
List<String> targetList = Arrays.stream(targets.split(","))
.map(String::trim).filter(s -> !s.isEmpty()).distinct().toList();
if (originFile.isBlank() || lineNo == null || lineNo <= 0 || targetList.isEmpty()) {
return Uni.createFrom().item(support.error("INVALID_REQUEST",
"originFile, a positive lineNo and at least one target are required"));
}
return withProject(project, () -> graphRepository.upsertDynamicCallOverride(project, originFile, lineNo,
targetList, variable, note, "mcp")
.map(result -> {
DynamicCallOverride ov = result.override();
return ov != null
? support.ok(ov)
: support.error("UNKNOWN_TARGET",
"not real MODULE(s) in project '" + project + "': " + result.invalidTargets());
}));
}
@Tool(name = "reset_dynamic_call_override", description = "Remove manual dynamic-CALLNAT overrides and restore the unresolved placeholder inline: pass originFile + lineNo for one call site, or omit both to reset every override in the project.")
@Blocking
public Uni<ToolResponse> resetDynamicCallOverride(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Call site file; omit (with lineNo) to reset all", required = false) @Nullable String originFile,
@ToolArg(description = "Call site line; omit (with originFile) to reset all", required = false) @Nullable Integer lineNo) {
return withProject(project, () -> graphRepository.resetDynamicCallOverride(project, originFile, lineNo)
.map(removed -> support.ok(Map.of("removed", removed))));
}
// -------------------------------------------------------------------------
// DB access
// -------------------------------------------------------------------------
@Tool(name = "module_columns", description = "Entity/table columns exposed by a module that maps to a DB table (e.g. a JPA @Entity).")
@Blocking
public Uni<ToolResponse> moduleColumns(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module name") String name) {
return withProject(project, () -> graphRepository.entityColumns(project, name).map(support::ok));
}
private static Collection<String> stepModules(List<DataflowStep> steps) {
return steps.stream().map(DataflowStep::module).filter(Objects::nonNull).distinct().toList();
}
@Tool(name = "callees", description = "Modules/functions the given module calls (plus Java INJECTS/REFERENCES wiring). 'scope' filters by edge kind; resolveInterfaces hops interface callees to their implementations.")
@Blocking
public Uni<ToolResponse> callees(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module name") String name,
@ToolArg(description = "Optional edge-kind scope filter", required = false) @Nullable String scope,
@ToolArg(description = "Page size (default 50)", required = false) @Nullable Integer limit,
@ToolArg(description = "Page offset (default 0)", required = false) @Nullable Integer offset,
@ToolArg(description = "Hop interface callees to their implementation(s) (Java)", required = false) @Nullable Boolean resolveInterfaces) {
return withProject(project, () -> graphRepository.callees(project, name, scope, effectiveLimit(limit), effectiveOffset(offset),
resolveInterfaces != null && resolveInterfaces).map(support::ok));
}
private static Collection<String> fieldFlowModules(List<FieldFlow> flows) {
Set<String> names = new LinkedHashSet<>();
for (FieldFlow flow : flows) {
names.add(flow.producer());
names.add(flow.consumer());
}
return names;
}
// -------------------------------------------------------------------------
// Search
// -------------------------------------------------------------------------
@Tool(name = "db_accesses", description = "DB tables a module reads/writes and the access mode. With 'depth' > 0, includes accesses of transitively-called modules.")
@Blocking
public Uni<ToolResponse> dbAccesses(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module name") String name,
@ToolArg(description = "Transitive depth (0/absent = direct only)", required = false) @Nullable Integer depth,
@ToolArg(description = "Page size (default: all rows)", required = false) @Nullable Integer limit,
@ToolArg(description = "Page offset (default 0)", required = false) @Nullable Integer offset) {
int effLimit = uncappedLimit(limit);
int effOffset = effectiveOffset(offset);
return withProject(project, () -> depth != null && depth > 0
? graphRepository.dbAccessesTransitive(project, name, depth, effLimit, effOffset).map(support::ok)
: graphRepository.dbAccesses(project, name, effLimit, effOffset).map(support::ok));
}
@Tool(name = "workfile_accesses", description = "Natural work files (sequential/flat file I/O) a module reads/writes: work-file number, mode (READS/WRITES), record buffers, and physical name from DEFINE WORK FILE. Separate from db_accesses (work files are not DB tables).")
@Blocking
public Uni<ToolResponse> workfileAccesses(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module name") String name,
@ToolArg(description = "Page size (default: all rows)", required = false) @Nullable Integer limit,
@ToolArg(description = "Page offset (default 0)", required = false) @Nullable Integer offset) {
int effLimit = uncappedLimit(limit);
int effOffset = effectiveOffset(offset);
return withProject(project, () -> graphRepository.workfileAccesses(project, name, effLimit, effOffset).map(support::ok));
}
@Tool(name = "sql_statements", description = "SQL/ADABAS statements issued by a module. With 'depth' > 0, includes statements of transitively-called modules.")
@Blocking
public Uni<ToolResponse> sqlStatements(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module name") String name,
@ToolArg(description = "Transitive depth (0/absent = direct only)", required = false) @Nullable Integer depth) {
return withProject(project, () -> depth != null && depth > 0
? graphRepository.sqlStatementsTransitive(project, name, depth).map(support::ok)
: graphRepository.sqlStatements(project, name).map(support::ok));
}
// -------------------------------------------------------------------------
// Variable access & dataflow
// -------------------------------------------------------------------------
@Tool(name = "data_structure_fields", description = "Fields of a data structure (Natural DEFINE DATA / Java DTO): name, type, level, redefines. Each field carries its sourceFile.")
@Blocking
public Uni<ToolResponse> dataStructureFields(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Data-structure name") String name,
@ToolArg(description = "Restrict to the definition in this source file (only needed when the name is not unique)",
required = false) @Nullable String sourceFile) {
return withProject(project, () -> graphRepository.dataStructureFields(project, name, sourceFile).map(support::ok));
}
@Tool(name = "db_table_columns", description = "Columns of a DB table (ADABAS view / SQL table).")
@Blocking
public Uni<ToolResponse> dbTableColumns(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "DB table name") String name) {
return withProject(project, () -> graphRepository.dbTableColumns(project, name).map(support::ok));
}
@Tool(name = "callers", description = "Callers of the given module. Default/'external' = modules that call it (CALLNAT/inheritance), each rolled up to the calling module (a call from inside a subroutine is attributed to its module, not the FUNCTION; repeated sites collapse to one row); 'internal' = its own subroutines' PERFORM wiring. Finer function-level callers: function_callers.")
@Blocking
public Uni<ToolResponse> callers(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module name") String name,
@ToolArg(description = "Scope: 'external' (default) = module callers; 'internal' = own-subroutine PERFORM callers", required = false) @Nullable String scope,
@ToolArg(description = "Page size (default 50)", required = false) @Nullable Integer limit,
@ToolArg(description = "Page offset (default 0)", required = false) @Nullable Integer offset) {
return withFanoutWarm(project,
() -> graphRepository.callers(project, name, scope, effectiveLimit(limit), effectiveOffset(offset)),
CallRefResponse::sourceFiles);
}
@Tool(name = "search_value", description = "Find nodes whose literal/assigned value matches the given string across a project.")
@Blocking
public Uni<ToolResponse> searchValue(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Value to search for") String value,
@ToolArg(description = "If true, case-insensitive substring match instead of exact", required = false) @Nullable Boolean contains,
@ToolArg(description = "Page size (default 50)", required = false) @Nullable Integer limit,
@ToolArg(description = "Page offset (default 0)", required = false) @Nullable Integer offset) {
if (value.isBlank()) {
return Uni.createFrom().item(support.error("MISSING_VALUE", "Argument 'value' is required"));
}
return withProject(project, () -> graphRepository.searchByValue(project, value, Boolean.TRUE.equals(contains),
effectiveLimit(limit), effectiveOffset(offset)).map(support::ok));
}
@Tool(name = "search_annotation", description = "Find nodes (Java only) carrying an annotation whose name matches (substring, case-insensitive), optionally filtered by node type.")
@Blocking
public Uni<ToolResponse> searchAnnotation(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Annotation name / substring") String name,
@ToolArg(description = "Optional node type filter", required = false) @Nullable String type,
@ToolArg(description = "Page size (default 50)", required = false) @Nullable Integer limit,
@ToolArg(description = "Page offset (default 0)", required = false) @Nullable Integer offset) {
if (name.isBlank()) {
return Uni.createFrom().item(support.error("MISSING_NAME", "Argument 'name' is required"));
}
if (type != null) {
try {
NodeType.valueOf(type.toUpperCase());
} catch (IllegalArgumentException e) {
return Uni.createFrom().item(support.error("INVALID_TYPE", "Unknown node type '" + type + "'"));
}
}
return withProject(project, () -> graphRepository.searchAnnotation(project, name,
type != null ? type.toUpperCase() : null, effectiveLimit(limit), effectiveOffset(offset)).map(support::ok));
}
@Tool(name = "inspect_node", description = "Every property of a single node by id (roadmap item 27). Node ids are regenerated on re-ingest, so only valid until the next one.")
@Blocking
public Uni<ToolResponse> inspectNode(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Node id") String id) {
return withProject(project, () -> graphRepository.nodeById(project, id).map(node -> node == null
? support.error("NODE_NOT_FOUND", "No node with id '" + id + "' in project '" + project + "' (ids are regenerated on re-ingest)")
: support.ok(node)));
}
@Tool(name = "call_tree", description = "Transitive call tree rooted at a module, up to 'depth' hops (clamped to the configured maximum). resolveInterfaces drops dead-end interface nodes whose implementations are already reached. followWiring also traverses Java INJECTS/REFERENCES edges (DI/class-literal wiring), not just CALLS.")
@Blocking
public Uni<ToolResponse> callTree(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module name") String name,
@ToolArg(description = "Traversal depth (default configured, clamped 1..max)", required = false) @Nullable Integer depth,
@ToolArg(description = "Drop interface nodes that have a known implementation (Java)", required = false) @Nullable Boolean resolveInterfaces,
@ToolArg(description = "Also traverse INJECTS/REFERENCES edges (Java DI/class-literal wiring), not just CALLS", required = false) @Nullable Boolean followWiring) {
int effectiveDepth = Math.clamp(depth != null ? depth : defaultCallTreeDepth, 1, maxCallTreeDepth);
return withFanoutWarm(project,
() -> graphRepository.callTree(project, name, effectiveDepth,
resolveInterfaces != null && resolveInterfaces, followWiring != null && followWiring,
callTreeInternalBudget),
CallTreeResponse::sourceFiles);
}
@Tool(name = "ego_graph", description = "Bounded call-graph neighbourhood (nodes + edges) around one module, at module granularity (item 49). Unlike call_tree it returns the edges too, so a caller can render an interactive sub-graph. 'direction' is out (callees), in (callers), or both; 'depth' is the max call hops (clamped to the configured maximum); 'limit' caps the number of nodes (BFS order) and sets 'truncated' when hit. Unresolved dynamic/cross-file targets appear as nodes with an empty sourceFile and unresolved=true.")
@Blocking
public Uni<ToolResponse> egoGraph(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module name") String name,
@ToolArg(description = "Max call hops from the module (default configured, clamped 1..max)", required = false) @Nullable Integer depth,
@ToolArg(description = "Traversal direction: out (callees), in (callers), or both (default out)", required = false) @Nullable String direction,
@ToolArg(description = "Cap on number of nodes returned (default 50, BFS order)", required = false) @Nullable Integer limit) {
int effectiveDepth = Math.clamp(depth != null ? depth : defaultCallTreeDepth, 1, maxCallTreeDepth);
String dir = direction != null ? direction : "out";
int effectiveLimit = effectiveLimit(limit);
return withProject(project, () -> graphRepository.egoGraph(project, name, effectiveDepth, dir, effectiveLimit)
.map(support::ok));
}
@Tool(name = "search_identifier", description = "Find identifiers across a project by exact name and optional node type (MODULE, FUNCTION, VARIABLE, DATA_STRUCTURE, DB_TABLE). Name match is sigil-insensitive: a leading Natural sigil (# user, & AIV, + GDA) is ignored on both sides, so 'K-OUT-MAX' matches the declared '#K-OUT-MAX'. Optionally scope to one source file or one module (by name) to pinpoint the local declaration when a name recurs across modules; or use priorityModule to keep the global list but pin one module's matches to the front so they survive the limit.")
@Blocking
public Uni<ToolResponse> searchIdentifier(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Identifier name", required = false) @Nullable String name,
@ToolArg(description = "Optional node type filter", required = false) @Nullable String type,
@ToolArg(description = "Scope to this exact source file (relative path)", required = false) @Nullable String sourceFile,
@ToolArg(description = "Scope to nodes of this module (by name)", required = false) @Nullable String module,
@ToolArg(description = "Do not filter, but pin this module's matches to the front so its local declaration survives the limit when a name recurs across many modules", required = false) @Nullable String priorityModule,
@ToolArg(description = "Page size (default 50)", required = false) @Nullable Integer limit,
@ToolArg(description = "Page offset (default 0)", required = false) @Nullable Integer offset) {
if (type != null) {
try {
NodeType.valueOf(type.toUpperCase());
} catch (IllegalArgumentException e) {
return Uni.createFrom().item(support.error("INVALID_TYPE", "Unknown node type '" + type + "'"));
}
}
return withFanoutWarm(project,
() -> graphRepository.searchIdentifier(project, name,
type != null ? type.toUpperCase() : null, sourceFile, module, priorityModule, effectiveLimit(limit), effectiveOffset(offset)),
matches -> matches.stream().map(IdentifierMatch::sourceFile).filter(sf -> !sf.isEmpty()).distinct().toList());
}
@Tool(name = "node_source", description = "Source-text snippet for a single node by id (roadmap item 28), read from the project root on disk.")
@Blocking
public Uni<ToolResponse> nodeSource(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Node id") String id) {
return withProject(project, () -> graphRepository.nodeById(project, id).flatMap(node -> {
if (node == null) {
return Uni.createFrom().item(support.error("NODE_NOT_FOUND", "No node with id '" + id + "' in project '" + project + "' (ids are regenerated on re-ingest)"));
}
String sourceFile = (String) node.get("sourceFile");
if (sourceFile == null || sourceFile.isEmpty()) {
return Uni.createFrom().item(support.error("NO_SOURCE_FILE", "Node '" + id + "' is an unresolved placeholder with no source file"));
}
int startLine = ((Number) Objects.requireNonNull(node.get("startLine"))).intValue();
int endLine = ((Number) Objects.requireNonNull(node.get("endLine"))).intValue();
return sourceSnippet(project, sourceFile, startLine, endLine);
}));
}
@Tool(name = "module_source", description = "Source text for a module by name (roadmap item 28), read from the project root on disk. Omit both startLine and endLine to get the whole file (M1); or pass both for a line range.")
@Blocking
public Uni<ToolResponse> moduleSource(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module name") String name,
@ToolArg(description = "Start line (1-indexed); omit with endLine for the whole file", required = false) @Nullable Integer startLine,
@ToolArg(description = "End line (1-indexed, inclusive); omit with startLine for the whole file", required = false) @Nullable Integer endLine) {
boolean wholeFile = startLine == null && endLine == null;
if (!wholeFile && (startLine == null || endLine == null)) {
return Uni.createFrom().item(support.error("MISSING_LINE_RANGE",
"Supply both startLine and endLine, or neither (whole file)"));
}
int sl = startLine != null ? startLine : 1;
int el = endLine != null ? endLine : Integer.MAX_VALUE;
return withProject(project, () -> graphRepository.moduleSourceFile(project, name).flatMap(sourceFile -> sourceFile == null
? Uni.createFrom().item(support.error("MODULE_NOT_FOUND", "No module '" + name + "' in project '" + project + "'"))
: wholeFile
? sourceSnippetWhole(project, sourceFile)
: sourceSnippet(project, sourceFile, sl, el)));
}
@Tool(name = "file_source", description = "Source text for a project file by its relative path (not a module name), read from the project root on disk. Use for files that aren't standalone modules — e.g. a Natural data area (PDA/LDA) USING'd by a module, whose fields' line numbers refer to that file. Omit both startLine and endLine to get the whole file; or pass both for a line range.")
@Blocking
public Uni<ToolResponse> fileSource(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Relative source file path (as reported by e.g. module_data_structures.sourceFile)") String file,
@ToolArg(description = "Start line (1-indexed); omit with endLine for the whole file", required = false) @Nullable Integer startLine,
@ToolArg(description = "End line (1-indexed, inclusive); omit with startLine for the whole file", required = false) @Nullable Integer endLine) {
if (file == null || file.isBlank()) {
return Uni.createFrom().item(support.error("MISSING_FILE", "'file' (relative source path) is required"));
}
boolean wholeFile = startLine == null && endLine == null;
if (!wholeFile && (startLine == null || endLine == null)) {
return Uni.createFrom().item(support.error("MISSING_LINE_RANGE",
"Supply both startLine and endLine, or neither (whole file)"));
}
int sl = startLine != null ? startLine : 1;
int el = endLine != null ? endLine : Integer.MAX_VALUE;
return withProject(project, () -> wholeFile ? sourceSnippetWhole(project, file) : sourceSnippet(project, file, sl, el));
}
@Tool(name = "variable_reads", description = "Functions that read a given variable, following the call graph up to 'depth' hops.")
@Blocking
public Uni<ToolResponse> variableReads(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Variable name") String name,
@ToolArg(description = "Optional module to scope to", required = false) @Nullable String module,
@ToolArg(description = "Traversal depth (default configured, clamped 1..max)", required = false) @Nullable Integer depth) {
int effectiveDepth = Math.clamp(depth != null ? depth : defaultCallTreeDepth, 1, maxCallTreeDepth);
return withProject(project, () -> graphRepository.variableReads(project, name, module, effectiveDepth).map(support::ok));
}
// -------------------------------------------------------------------------
// Helpers (mirror AnalysisResource)
// -------------------------------------------------------------------------
@Tool(name = "variable_writes", description = "Functions that write a given variable, following the call graph up to 'depth' hops.")
@Blocking
public Uni<ToolResponse> variableWrites(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Variable name") String name,
@ToolArg(description = "Optional module to scope to", required = false) @Nullable String module,
@ToolArg(description = "Traversal depth (default configured, clamped 1..max)", required = false) @Nullable Integer depth) {
int effectiveDepth = Math.clamp(depth != null ? depth : defaultCallTreeDepth, 1, maxCallTreeDepth);
return withProject(project, () -> graphRepository.variableWrites(project, name, module, effectiveDepth).map(support::ok));
}
/**
* Looks up {@code sourceFile}'s stored content hash (item 41), then reads its {@code [startLine,
* endLine]} slice with a stale check.
*/
private Uni<ToolResponse> sourceSnippet(String project, String sourceFile, int startLine, int endLine) {
return graphRepository.sourceHash(project, sourceFile)
.map(expectedHash -> readSnippet(project, sourceFile,
(root, hash) -> sourceSnippetService.read(root, sourceFile, startLine, endLine, hash), expectedHash));
}
/**
* M1: whole-file variant of {@link #sourceSnippet}.
*/
private Uni<ToolResponse> sourceSnippetWhole(String project, String sourceFile) {
return graphRepository.sourceHash(project, sourceFile)
.map(expectedHash -> readSnippet(project, sourceFile,
(root, hash) -> sourceSnippetService.readWholeFile(root, sourceFile, hash), expectedHash));
}
/**
* Resolves the project root and runs {@code reader} against it, mapping root-resolution/IO
* failures to MCP tool errors. When the file on disk no longer matches the stored hash (item 41),
* returns a {@code STALE_SOURCE} error telling the caller to re-ingest, rather than serving
* changed text against stale line numbers.
*/
private ToolResponse readSnippet(String project, String sourceFile, SnippetReader reader,
@Nullable String expectedHash) {
String root;
switch (rootResolver.resolve(project)) {
case ProjectRootResolver.Resolved r -> root = r.project().root();
case ProjectRootResolver.Failed failed -> {
return support.error(failed.code(), failed.message());
}
}
// Defence in depth: file_source takes a client-supplied path, so reject any that escapes root.
Path base = Path.of(root).toAbsolutePath().normalize();
if (!base.resolve(sourceFile).normalize().startsWith(base)) {
return support.error("INVALID_SOURCE_FILE", "Source file '" + sourceFile + "' escapes the project root");
}
try {
return support.ok(reader.read(root, expectedHash));
} catch (StaleSourceException e) {
return support.error("STALE_SOURCE", e.detail());
} catch (IOException e) {
return support.error("SOURCE_READ_FAILED", "Failed to read '" + sourceFile + "': " + e.getMessage());
}
}
@FunctionalInterface
private interface SnippetReader {
SourceSnippet read(String root, @Nullable String expectedHash) throws IOException, StaleSourceException;
}
@Tool(name = "flow_forward", description = "Forward field-level dataflow from a variable (where its value flows to). Requires the module to be deep-ingested.")
@Blocking
public Uni<ToolResponse> flowForward(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Variable name") String name,
@ToolArg(description = "Module the variable lives in", required = false) @Nullable String module,
@ToolArg(description = "Traversal depth (default configured)", required = false) @Nullable Integer depth) {
int effectiveDepth = depth != null ? depth : defaultCallTreeDepth;
return withFlowPathWarm(project, module,
() -> graphRepository.flowForward(project, name, module, effectiveDepth),
McpQueryTools::stepModules);
}
/**
* Runs {@code action} if the project exists, otherwise a {@code PROJECT_NOT_FOUND} tool error.
*/
private Uni<ToolResponse> withProject(String project, Supplier<Uni<ToolResponse>> action) {
return graphRepository.projectExists(project).flatMap(exists -> exists
? action.get()
: Uni.createFrom().item(support.error("PROJECT_NOT_FOUND", "Project '" + project + "' does not exist")));
}
@Tool(name = "flow_backward", description = "Backward field-level dataflow into a variable (where its value comes from). Requires the module to be deep-ingested.")
@Blocking
public Uni<ToolResponse> flowBackward(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Variable name") String name,
@ToolArg(description = "Module the variable lives in", required = false) @Nullable String module,
@ToolArg(description = "Traversal depth (default configured)", required = false) @Nullable Integer depth) {
int effectiveDepth = depth != null ? depth : defaultCallTreeDepth;
return withFlowPathWarm(project, module,
() -> graphRepository.flowBackward(project, name, module, effectiveDepth),
McpQueryTools::stepModules);
}
@Tool(name = "field_flow", description = "Producer/consumer field-flow pairs for a variable within its module. Requires the module to be deep-ingested.")
@Blocking
public Uni<ToolResponse> fieldFlow(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Variable name") String name,
@ToolArg(description = "Module the variable lives in", required = false) @Nullable String module,
@ToolArg(description = "Traversal depth (default configured)", required = false) @Nullable Integer depth) {
int effectiveDepth = depth != null ? depth : defaultCallTreeDepth;
return withFlowPathWarm(project, module,
() -> graphRepository.fieldFlow(project, name, module, effectiveDepth),
McpQueryTools::fieldFlowModules);
}
/**
* Fan-out / traversal deep-ingest warm (item 37, blocking-then-rerun): runs {@code query} against
* the graph as-is, deep-ingests the modules the result surfaced ({@code surfaced}) via
* {@link DeepIngestCoordinator#ensureDeepMany}, and — only if that warm actually deepened
* something — re-runs {@code query} against the now-deeper graph so the response reflects
* newly-resolved dynamic dispatch. When nothing changed (already deep, empty set, or the warm
* failed) the first result is returned directly, so the query never pays for a redundant re-run.
*
* @param surfaced maps a result to the {@code sourceFile} paths it surfaced (the warm target)
*/
private <T> Uni<ToolResponse> withFanoutWarm(String project, Supplier<Uni<T>> query,
Function<T, Collection<String>> surfaced) {
return withProject(project, () -> query.get().flatMap(first ->
deepIngestCoordinator.ensureDeepMany(project, surfaced.apply(first)).flatMap(changed ->
changed ? query.get().map(support::ok) : Uni.createFrom().item(support.ok(first)))));
}
/**
* Cross-module {@code flow_*} path-ingest (item 37a), the MCP counterpart of
* {@code AnalysisResource.withFlowPathWarm}: deep-ingests the named start {@code module} (falling
* back to the agent-actionable deep-ingest hint if it does not resolve to {@code FULL}), then runs
* {@code query} and drives a bounded ingest-and-re-traverse fixpoint via {@link #flowFixpoint} —
* each round deep-ingests the frontier the current result surfaced
* ({@link DeepIngestCoordinator#ensureFlowFrontier}) and re-runs the trace, so the flow crosses
* into callees (notably dynamically-dispatched ones) as they become deeply ingested. With no
* {@code module} it is a plain project-wide query.
*/
private <T> Uni<ToolResponse> withFlowPathWarm(String project, @Nullable String module,
Supplier<Uni<List<T>>> query,
Function<List<T>, Collection<String>> surfaced) {
if (module == null || module.isBlank()) {
return withProject(project, () -> query.get().map(support::ok));
}
String startModule = module;
return withProject(project, () -> deepIngestCoordinator.ensureDeep(project, startModule)
.flatMap(ignored -> graphRepository.moduleIngestState(project, startModule).flatMap(state ->
state.isFull()
? flowFixpoint(project, startModule, query, surfaced, deepIngestCoordinator.flowRounds())
: Uni.createFrom().item(support.errorBody(deepIngestHint(startModule, state))))));
}
/**
* One iteration of the flow path-ingest fixpoint: runs {@code query}, and while rounds remain and
* {@link DeepIngestCoordinator#ensureFlowFrontier} reports it deepened the frontier, re-traverses;
* otherwise returns the current result. The frontier seed always includes the start {@code module}
* (not just what the trace surfaced) so the first round pulls in the start module's not-yet-deep —
* notably dynamically-dispatched — callees even when the trace is still empty. Bounded by
* {@code roundsLeft} (configured {@code flow-rounds}) so it terminates even if the graph keeps
* surfacing new callees.
*/
private <T> Uni<ToolResponse> flowFixpoint(String project, String module, Supplier<Uni<List<T>>> query,
Function<List<T>, Collection<String>> surfaced, int roundsLeft) {
return query.get().flatMap(result -> {
if (roundsLeft <= 0) {
return Uni.createFrom().item(support.ok(result));
}
Set<String> frontier = new LinkedHashSet<>();
frontier.add(module);
frontier.addAll(surfaced.apply(result));
return deepIngestCoordinator.ensureFlowFrontier(project, frontier).flatMap(changed ->
changed
? flowFixpoint(project, module, query, surfaced, roundsLeft - 1)
: Uni.createFrom().item(support.ok(result)));
});
}
/**
* Agent-actionable hint: a deep query needs the module (deep-)ingested first via the named MCP tool.
*/
public record DeepIngestRequired(String status, String module, String detail, String nextTool) {
}
}