This commit is contained in:
Ingo Schnabel
2026-07-07 07:53:36 +02:00
parent 56fa3fe0a3
commit e03cba97e0
13 changed files with 903 additions and 65 deletions

View File

@@ -50,6 +50,10 @@
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-health</artifactId>
</dependency>
<dependency>
<groupId>io.quarkiverse.mcp</groupId>
<artifactId>quarkus-mcp-server-sse</artifactId>
</dependency>
<dependency>
<groupId>org.jspecify</groupId>
<artifactId>jspecify</artifactId>
@@ -70,6 +74,11 @@
<artifactId>testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.quarkiverse.mcp</groupId>
<artifactId>quarkus-mcp-server-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>

View File

@@ -2,6 +2,7 @@ package com.agenticcode.codeserver.api;
import com.agenticcode.codeserver.service.IngestSummary;
import com.agenticcode.codeserver.service.ProjectIngestService;
import com.agenticcode.codeserver.service.ProjectRootResolver;
import com.agenticcode.neo4jstore.graph.*;
import com.agenticcode.parsercore.ast.model.NodeType;
import io.smallrye.common.annotation.Blocking;
@@ -13,7 +14,6 @@ import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jspecify.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
@@ -35,14 +35,17 @@ import java.util.function.Supplier;
public class AnalysisResource {
private final ProjectIngestService projectIngestService;
private final ProjectRootResolver rootResolver;
private final GraphRepository graphRepository;
private final int defaultCallTreeDepth;
private final int maxCallTreeDepth;
public AnalysisResource(ProjectIngestService projectIngestService, GraphRepository graphRepository,
public AnalysisResource(ProjectIngestService projectIngestService, ProjectRootResolver rootResolver,
GraphRepository graphRepository,
@ConfigProperty(name = "agenticcode.call-tree.default-depth", defaultValue = "3") int defaultCallTreeDepth,
@ConfigProperty(name = "agenticcode.call-tree.max-depth", defaultValue = "10") int maxCallTreeDepth) {
this.projectIngestService = projectIngestService;
this.rootResolver = rootResolver;
this.graphRepository = graphRepository;
this.defaultCallTreeDepth = defaultCallTreeDepth;
this.maxCallTreeDepth = maxCallTreeDepth;
@@ -80,18 +83,14 @@ public class AnalysisResource {
* {@code 500 INGEST_FAILED} (the root could not be walked).
*/
private Response withResolvedRoot(String project, RootIngest ingest) {
ProjectInfo info = graphRepository.getProject(project).await().indefinitely();
if (info == null) {
return ProjectResource.error(Response.Status.NOT_FOUND, "PROJECT_NOT_FOUND",
"Project '" + project + "' does not exist");
}
if (info.root().isBlank()) {
return ProjectResource.error(Response.Status.BAD_REQUEST, "ROOT_NOT_SET",
"Project '" + project + "' has no root folder; set one with 'project update'");
}
if (!Files.isDirectory(java.nio.file.Path.of(info.root()))) {
return ProjectResource.error(Response.Status.BAD_REQUEST, "ROOT_NOT_FOUND",
"Project root '" + info.root() + "' is not a directory on the server");
ProjectInfo info;
switch (rootResolver.resolve(project)) {
case ProjectRootResolver.Resolved r -> info = r.project();
case ProjectRootResolver.Failed failed -> {
Response.Status status = "PROJECT_NOT_FOUND".equals(failed.code())
? Response.Status.NOT_FOUND : Response.Status.BAD_REQUEST;
return ProjectResource.error(status, failed.code(), failed.message());
}
}
try {
return Response.ok(ingest.run(info)).build();

View File

@@ -0,0 +1,84 @@
package com.agenticcode.codeserver.mcp;
import com.agenticcode.codeserver.service.IngestSummary;
import com.agenticcode.codeserver.service.ProjectIngestService;
import com.agenticcode.codeserver.service.ProjectRootResolver;
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 jakarta.enterprise.context.ApplicationScoped;
import org.jspecify.annotations.Nullable;
import java.io.IOException;
/**
* MCP tools mirroring the ingest endpoints of {@code AnalysisResource}. Each tool resolves and
* validates the project's root folder via the shared {@link ProjectRootResolver} (same guards as
* REST: {@code PROJECT_NOT_FOUND} / {@code ROOT_NOT_SET} / {@code ROOT_NOT_FOUND}), then runs the
* corresponding {@link ProjectIngestService} operation and returns the {@link IngestSummary} as JSON.
*
* <p>All tools are {@link Blocking}: ingest walks the file system and persists to Neo4j.
* Destructive project CRUD (create/update/delete/clearAll) is intentionally <em>not</em> exposed.
*/
@ApplicationScoped
public class McpIngestTools {
private final ProjectIngestService ingestService;
private final ProjectRootResolver rootResolver;
private final McpSupport support;
public McpIngestTools(ProjectIngestService ingestService, ProjectRootResolver rootResolver, McpSupport support) {
this.ingestService = ingestService;
this.rootResolver = rootResolver;
this.support = support;
}
@Tool(name = "ingest_all", description = "Scan the project's root folder and ingest every source file into the graph. Set deep=true for field-level dataflow (slower).")
@Blocking
public ToolResponse ingestAll(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Deep (field-level) ingest", required = false) @Nullable Boolean deep) {
boolean deepIngest = deep != null && deep;
return run(project, info -> ingestService.ingestAll(info, deepIngest));
}
@Tool(name = "ingest_module", description = "Ingest (and deep-ingest) a single module by name, resolving it under the project's root folder.")
@Blocking
public ToolResponse ingestModule(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module name") String name) {
return run(project, info -> ingestService.ingestModule(info, name));
}
@Tool(name = "ingest_call_graph", description = "Ingest only the call graph (PERFORM/CALLNAT/method calls) for the whole project root, without field-level dataflow.")
@Blocking
public ToolResponse ingestCallGraph(
@ToolArg(description = "Project name") String project) {
return run(project, ingestService::ingestCallGraph);
}
/**
* Resolves the project root, maps a validation {@link ProjectRootResolver.Failed} to a tool error,
* otherwise runs {@code ingest} and returns its {@link IngestSummary} (or an {@code INGEST_FAILED}
* tool error if the root could not be scanned).
*/
private ToolResponse run(String project, RootIngest ingest) {
return switch (rootResolver.resolve(project)) {
case ProjectRootResolver.Failed failed -> support.error(failed.code(), failed.message());
case ProjectRootResolver.Resolved resolved -> {
try {
yield support.ok(ingest.run(resolved.project()));
} catch (IOException e) {
yield support.error("INGEST_FAILED",
"Failed to scan root '" + resolved.project().root() + "': " + e.getMessage());
}
}
};
}
@FunctionalInterface
private interface RootIngest {
IngestSummary run(com.agenticcode.neo4jstore.graph.ProjectInfo project) throws IOException;
}
}

View File

@@ -0,0 +1,345 @@
package com.agenticcode.codeserver.mcp;
import com.agenticcode.neo4jstore.graph.GraphRepository;
import com.agenticcode.neo4jstore.graph.ModuleIngestState;
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.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
public class McpQueryTools {
private static final int DEFAULT_PAGE_LIMIT = 50;
private final GraphRepository graphRepository;
private final McpSupport support;
private final int defaultCallTreeDepth;
private final int maxCallTreeDepth;
// -------------------------------------------------------------------------
// Projects & module discovery
// -------------------------------------------------------------------------
public McpQueryTools(GraphRepository graphRepository, McpSupport support,
@ConfigProperty(name = "agenticcode.call-tree.default-depth", defaultValue = "3") int defaultCallTreeDepth,
@ConfigProperty(name = "agenticcode.call-tree.max-depth", defaultValue = "10") int maxCallTreeDepth) {
this.graphRepository = graphRepository;
this.support = support;
this.defaultCallTreeDepth = defaultCallTreeDepth;
this.maxCallTreeDepth = maxCallTreeDepth;
}
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;
}
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).")
@Blocking
public Uni<ToolResponse> listProjects() {
return graphRepository.listProjects().map(support::ok);
}
@Tool(name = "list_modules", description = "List MODULE nodes (name, sourceFile) in a project, optionally filtered to those defined in a given source file.")
@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) {
return withProject(project, () -> graphRepository.listModules(project, 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.")
@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) {
boolean inherited = includeInherited != null && includeInherited;
return withProject(project, () -> graphRepository.moduleFunctions(project, name, inherited).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_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));
}
// -------------------------------------------------------------------------
// 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));
}
@Tool(name = "callers", description = "Modules/functions that call the given module. 'scope' filters by edge kind.")
@Blocking
public Uni<ToolResponse> callers(
@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) {
return withProject(project, () -> graphRepository.callers(project, name, scope, effectiveLimit(limit), effectiveOffset(offset)).map(support::ok));
}
@Tool(name = "callees", description = "Modules/functions the given module calls. 'scope' filters by edge kind.")
@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) {
return withProject(project, () -> graphRepository.callees(project, name, scope, effectiveLimit(limit), effectiveOffset(offset)).map(support::ok));
}
@Tool(name = "call_tree", description = "Transitive call tree rooted at a module, up to 'depth' hops (clamped to the configured maximum).")
@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) {
int effectiveDepth = Math.clamp(depth != null ? depth : defaultCallTreeDepth, 1, maxCallTreeDepth);
return withProject(project, () -> graphRepository.callTree(project, name, effectiveDepth).map(support::ok));
}
// -------------------------------------------------------------------------
// 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 50)", required = false) @Nullable Integer limit,
@ToolArg(description = "Page offset (default 0)", required = false) @Nullable Integer offset) {
int effLimit = effectiveLimit(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 = "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.")
@Blocking
public Uni<ToolResponse> dataStructureFields(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Data-structure name") String name) {
return withProject(project, () -> graphRepository.dataStructureFields(project, name).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 = "search_identifier", description = "Find identifiers across a project by name (substring) and optional node type (MODULE, FUNCTION, VARIABLE, DATA_STRUCTURE, DB_TABLE).")
@Blocking
public Uni<ToolResponse> searchIdentifier(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Identifier name / substring", required = false) @Nullable 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 (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.searchIdentifier(project, name,
type != null ? type.toUpperCase() : null, effectiveLimit(limit), effectiveOffset(offset)).map(support::ok));
}
@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 = "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, effectiveLimit(limit), effectiveOffset(offset)).map(support::ok));
}
@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));
}
@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 withDeepModule(project, module, () -> graphRepository.flowForward(project, name, module, effectiveDepth).map(support::ok));
}
@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 withDeepModule(project, module, () -> graphRepository.flowBackward(project, name, module, effectiveDepth).map(support::ok));
}
@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 withDeepModule(project, module, () -> graphRepository.fieldFlow(project, name, module, effectiveDepth).map(support::ok));
}
/**
* 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")));
}
/**
* Like {@link #withProject}, but when {@code module} is given it first checks the module has been
* deeply (field-level) ingested, returning an agent-actionable hint (call {@code ingest_module})
* instead of a misleading empty result. With no {@code module} the check is skipped.
*/
private Uni<ToolResponse> withDeepModule(String project, @Nullable String module, Supplier<Uni<ToolResponse>> action) {
if (module == null || module.isBlank()) {
return withProject(project, action);
}
return withProject(project, () -> graphRepository.moduleIngestState(project, module).flatMap(state ->
state.isFull()
? action.get()
: Uni.createFrom().item(support.errorBody(deepIngestHint(module, state)))));
}
/**
* 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) {
}
}

View File

@@ -0,0 +1,57 @@
package com.agenticcode.codeserver.mcp;
import com.agenticcode.codeserver.api.ErrorResponse;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.quarkiverse.mcp.server.ToolResponse;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.Map;
/**
* Serializes MCP tool results to the same concise JSON the REST API emits, so an agent gets an
* identical payload shape whether it calls a tool or an endpoint.
*
* <p>Success results become a JSON text content; error results reuse the REST
* {@link ErrorResponse} shape ({@code {"error","code","details"}}) and are flagged as MCP tool
* errors ({@code isError=true}).
*/
@ApplicationScoped
class McpSupport {
private final ObjectMapper mapper;
McpSupport(ObjectMapper mapper) {
this.mapper = mapper;
}
/**
* Success response carrying {@code value} serialized as JSON text.
*/
ToolResponse ok(Object value) {
return ToolResponse.success(toJson(value));
}
/**
* Error response with the REST {@link ErrorResponse} JSON shape and {@code isError=true}.
*/
ToolResponse error(String code, String message) {
return ToolResponse.error(toJson(new ErrorResponse(message, code, Map.of())));
}
/**
* Error response carrying an already-built structured body (e.g. a deep-ingest hint).
*/
ToolResponse errorBody(Object body) {
return ToolResponse.error(toJson(body));
}
private String toJson(Object value) {
try {
return mapper.writeValueAsString(value);
} catch (JsonProcessingException e) {
// Serialization of our own DTOs should never fail; surface loudly if it ever does.
throw new IllegalStateException("MCP JSON serialization failed for " + value.getClass(), e);
}
}
}

View File

@@ -0,0 +1,59 @@
package com.agenticcode.codeserver.service;
import com.agenticcode.neo4jstore.graph.GraphRepository;
import com.agenticcode.neo4jstore.graph.ProjectInfo;
import jakarta.enterprise.context.ApplicationScoped;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Loads a project and validates that its root folder is usable for ingest.
*
* <p>Shared by the REST {@code AnalysisResource} and the MCP ingest tools so the guard logic
* (project exists, root configured, root is a directory on the server) has a single source of
* truth and cannot drift between the two entry points. The result carries a transport-neutral
* {@code code}; each caller maps it to its own error shape (HTTP status / MCP tool error).
*/
@ApplicationScoped
public class ProjectRootResolver {
private final GraphRepository graphRepository;
public ProjectRootResolver(GraphRepository graphRepository) {
this.graphRepository = graphRepository;
}
/**
* Resolves {@code project}, returning {@link Failed} with code {@code PROJECT_NOT_FOUND} (no such
* project), {@code ROOT_NOT_SET} (no root configured) or {@code ROOT_NOT_FOUND} (root is not a
* directory on the server), otherwise {@link Resolved} with the loaded {@link ProjectInfo}.
*/
public Resolution resolve(String project) {
ProjectInfo info = graphRepository.getProject(project).await().indefinitely();
if (info == null) {
return new Failed("PROJECT_NOT_FOUND", "Project '" + project + "' does not exist");
}
if (info.root().isBlank()) {
return new Failed("ROOT_NOT_SET",
"Project '" + project + "' has no root folder; set one with 'project update'");
}
if (!Files.isDirectory(Path.of(info.root()))) {
return new Failed("ROOT_NOT_FOUND",
"Project root '" + info.root() + "' is not a directory on the server");
}
return new Resolved(info);
}
/**
* Either a resolved project ({@link Resolved}) or a validation {@link Failed} with a stable code.
*/
public sealed interface Resolution permits Resolved, Failed {
}
public record Resolved(ProjectInfo project) implements Resolution {
}
public record Failed(String code, String message) implements Resolution {
}
}

View File

@@ -1,5 +1,8 @@
# HTTP server port
quarkus.http.port=8787
# MCP server (HTTP/SSE transport) — tools exposed at http://<host>:8787/mcp/sse
quarkus.mcp.server.server-info.name=agenticcode
quarkus.mcp.server.server-info.version=1.0.0
# HTTP access log (INFO) for every API call
quarkus.http.access-log.enabled=true

View File

@@ -0,0 +1,133 @@
package com.agenticcode.codeserver.mcp;
import com.agenticcode.codeserver.api.ProjectResource;
import io.quarkiverse.mcp.server.TextContent;
import io.quarkiverse.mcp.server.test.McpAssured;
import io.quarkiverse.mcp.server.test.McpAssured.McpSseTestClient;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import static io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.*;
/**
* End-to-end test of the MCP HTTP/SSE endpoint: connects an MCP client, asserts the tools are
* registered, then drives the full pipeline through tools only — {@code ingest_all} scans the
* project root and persists to Neo4j, and the query tools read it back — mirroring what an agent
* does. The project itself is created via REST (project CRUD is deliberately not an MCP tool).
*/
@QuarkusTest
class McpToolsIT {
private static final String PROJECT = "mcp-test-project";
@TempDir
static Path root;
private static int testPort;
@BeforeAll
static void setUp() {
testPort = Integer.getInteger("quarkus.http.test-port", 8081);
RestAssured.port = testPort;
copyFixture("fixtures/natural/YADDRBN0_SAMPLE.nat");
copyFixture("fixtures/natural/VDB2_VERSIS_ADDRESS.pda");
given()
.contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, root.toString(), null))
.when().post("/api/projects/" + PROJECT)
.then().statusCode(201);
}
private static McpSseTestClient connect() {
return McpAssured.newSseClient()
.setBaseUri(URI.create("http://localhost:" + testPort))
.build()
.connect();
}
private static String text(io.quarkiverse.mcp.server.ToolResponse response) {
return ((TextContent) response.firstContent()).text();
}
private static void copyFixture(String classpathResource) {
String fileName = classpathResource.substring(classpathResource.lastIndexOf('/') + 1);
try (InputStream in = McpToolsIT.class.getClassLoader().getResourceAsStream(classpathResource)) {
if (in == null) {
throw new IllegalStateException("Resource not found: " + classpathResource);
}
Files.write(root.resolve(fileName), in.readAllBytes());
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
@Test
void toolsAreRegistered() {
McpSseTestClient client = connect();
client.when()
.toolsList(page -> {
for (String tool : List.of("list_projects", "list_modules", "callers", "callees",
"call_tree", "db_accesses", "search_identifier", "ingest_all", "ingest_module")) {
assertNotNull(page.findByName(tool), "tool not registered: " + tool);
}
})
.thenAssertResults();
client.disconnect();
}
@Test
void ingestAndQueryThroughTools() {
McpSseTestClient client = connect();
// Ingest the whole project root via the MCP tool.
client.when()
.toolsCall("ingest_all", Map.of("project", PROJECT, "deep", true), response -> {
assertFalse(response.isError(), "ingest_all returned an error: " + text(response));
assertTrue(text(response).contains("\"ingested\""), text(response));
})
.thenAssertResults();
// The ingested module is now listed.
client.when()
.toolsCall("list_modules", Map.of("project", PROJECT), response -> {
assertFalse(response.isError(), text(response));
assertTrue(text(response).contains("YADDRBN0_SAMPLE.nat"), text(response));
})
.thenAssertResults();
// list_projects surfaces the project we created.
client.when()
.toolsCall("list_projects", Map.of(), response -> {
assertFalse(response.isError(), text(response));
assertTrue(text(response).contains(PROJECT), text(response));
})
.thenAssertResults();
client.disconnect();
}
@Test
void unknownProjectIsAToolError() {
McpSseTestClient client = connect();
client.when()
.toolsCall("search_identifier", Map.of("project", "does-not-exist", "name", "X"), response -> {
assertTrue(response.isError(), "expected a tool error for a missing project");
assertTrue(text(response).contains("PROJECT_NOT_FOUND"), text(response));
})
.thenAssertResults();
client.disconnect();
}
}

13
pom.xml
View File

@@ -30,6 +30,7 @@
<quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
<quarkus.platform.version>3.36.2</quarkus.platform.version>
<quarkus-neo4j.version>6.6.1</quarkus-neo4j.version>
<mcp-server.version>1.9.1</mcp-server.version>
<javaparser.version>3.26.2</javaparser.version>
<junit.version>5.11.3</junit.version>
<compiler-plugin.version>3.13.0</compiler-plugin.version>
@@ -92,6 +93,18 @@
<version>${quarkus-neo4j.version}</version>
</dependency>
<dependency>
<groupId>io.quarkiverse.mcp</groupId>
<artifactId>quarkus-mcp-server-sse</artifactId>
<version>${mcp-server.version}</version>
</dependency>
<dependency>
<groupId>io.quarkiverse.mcp</groupId>
<artifactId>quarkus-mcp-server-test</artifactId>
<version>${mcp-server.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>

View File

@@ -498,3 +498,60 @@ enumerate names. Any other `fields` value (or none) returns the full shape.
- For a full reengineering pass on a feature, combine: `call-tree` (scope) →
`context` per module in scope → `data-structure-fields`/`db-table-columns`
for each referenced structure/table.
## Access via MCP
The same operations are exposed to AI agents as **MCP tools** over the Model
Context Protocol, in addition to REST. The server runs an HTTP/SSE MCP endpoint
at `http://<host>:8787/mcp/sse` (server name `agenticcode`). Register it with an
agent, e.g. Claude Code:
```
claude mcp add --transport sse agenticcode http://localhost:8787/mcp/sse
```
Each tool takes the same arguments as the corresponding REST endpoint — the
`{project}` path segment and every query parameter become named tool arguments
(e.g. `project`, `name`, `scope`, `depth`, `limit`, `offset`) — and returns the
**identical concise JSON** as its REST twin. A missing project (or other
validation failure) comes back as an MCP tool error (`isError=true`) carrying the
same `{ "error", "code", "details" }` body (`PROJECT_NOT_FOUND`, `ROOT_NOT_SET`,
`ROOT_NOT_FOUND`, `INVALID_TYPE`, `MISSING_VALUE`, `INGEST_FAILED`). The
field-level dataflow tools (`flow_forward`/`flow_backward`/`field_flow`) return an
actionable hint pointing at the `ingest_module` tool when the module has not been
deep-ingested.
REST endpoint → MCP tool:
| REST endpoint | MCP tool |
|---------------------------------------|--------------------------|
| `GET /modules` | `list_modules` |
| `GET /modules/{name}/digest` | `module_digest` |
| `GET /modules/{name}/context` | `module_context` |
| `GET /modules/{name}/functions` | `module_functions` |
| `GET /modules/{name}/data-structures` | `module_data_structures` |
| `GET /modules/{name}/dispatch-table` | `module_dispatch_table` |
| `GET /modules/{name}/columns` | `module_columns` |
| `GET /modules/{name}/callers` | `callers` |
| `GET /modules/{name}/callees` | `callees` |
| `GET /modules/{name}/call-tree` | `call_tree` |
| `GET /modules/{name}/db-accesses` | `db_accesses` |
| `GET /modules/{name}/sql-statements` | `sql_statements` |
| `GET /data-structures/{name}/fields` | `data_structure_fields` |
| `GET /db-tables/{name}/columns` | `db_table_columns` |
| `GET /search/identifier` | `search_identifier` |
| `GET /search/value` | `search_value` |
| `GET /variables/{name}/reads` | `variable_reads` |
| `GET /variables/{name}/writes` | `variable_writes` |
| `GET /variables/{name}/flow-forward` | `flow_forward` |
| `GET /variables/{name}/flow-backward` | `flow_backward` |
| `GET /variables/{name}/field-flow` | `field_flow` |
| `GET /api/projects` | `list_projects` |
| `POST /ingest-all` | `ingest_all` |
| `POST /ingest/{name}` | `ingest_module` |
| `POST /ingest-call-graph` | `ingest_call_graph` |
Project create/update/delete and the global clear are **not** exposed as MCP
tools (destructive); use the REST `ProjectResource` for those. The `?fields=name`
names-only projection (P1-w) is REST-only for now — MCP tools return the full
response shape.

View File

@@ -1,4 +1,4 @@
<svg width="100%" viewBox="0 0 680 500" role="img" xmlns="http://www.w3.org/2000/svg">
<svg width="100%" viewBox="0 0 860 500" role="img" xmlns="http://www.w3.org/2000/svg">
<title>AgenticCode — How an AI Agent Uses It</title>
<desc>An AI agent queries the AgenticCode API progressively: orient, call graph, data and DB, deep dive into
dataflow and source lines
@@ -19,79 +19,79 @@
<!-- ── AI Agent ── -->
<g>
<rect x="160" y="14" width="360" height="52" rx="8" stroke-width="0.5"
<rect x="250" y="14" width="360" height="52" rx="8" stroke-width="0.5"
style="fill:rgb(251,234,240);stroke:rgb(153,53,86);"/>
<text x="340" y="35" text-anchor="middle" dominant-baseline="central"
<text x="430" y="35" text-anchor="middle" dominant-baseline="central"
style="fill:rgb(114,36,62);font-family:'Anthropic Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:14px;font-weight:500;">
AI Agent (Claude / LLM)
</text>
<text x="340" y="54" text-anchor="middle" dominant-baseline="central"
<text x="430" y="54" text-anchor="middle" dominant-baseline="central"
style="fill:rgb(153,53,86);font-family:'Anthropic Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:12px;">
has a question about a Natural / Java codebase
</text>
</g>
<!-- bidirectional arrow Agent ↔ API -->
<line x1="340" y1="66" x2="340" y2="102" marker-end="url(#arrow)"
<line x1="440" y1="66" x2="440" y2="102" marker-end="url(#arrow)"
style="fill:none;stroke:rgb(115,114,108);stroke-width:1.5px;"/>
<line x1="330" y1="102" x2="330" y2="66" marker-end="url(#arrow)"
<line x1="420" y1="102" x2="420" y2="66" marker-end="url(#arrow)"
style="fill:none;stroke:rgb(115,114,108);stroke-width:1.5px;"/>
<text x="356" y="86" dominant-baseline="central"
<text x="456" y="86" dominant-baseline="central"
style="fill:rgb(95,94,90);font-family:'Anthropic Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:11px;">
queries · answers
</text>
<!-- ── AgenticCode API + Neo4j ── -->
<text x="340" y="100" text-anchor="middle"
<text x="430" y="100" text-anchor="middle"
style="fill:rgb(61,61,58);font-family:'Anthropic Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:12px;">
AgenticCode
</text>
<g>
<rect x="60" y="108" width="560" height="58" rx="10" stroke-width="0.5"
<rect x="40" y="108" width="780" height="58" rx="10" stroke-width="0.5"
style="fill:rgb(225,245,238);stroke:rgb(15,110,86);"/>
<text x="340" y="129" text-anchor="middle" dominant-baseline="central"
<text x="430" y="129" text-anchor="middle" dominant-baseline="central"
style="fill:rgb(8,80,65);font-family:'Anthropic Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:14px;font-weight:500;">
REST / MCP API · Neo4j Graph
</text>
<text x="340" y="150" text-anchor="middle" dominant-baseline="central"
<text x="430" y="150" text-anchor="middle" dominant-baseline="central"
style="fill:rgb(15,110,86);font-family:'Anthropic Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:12px;">
Natural + Java sources ingested as a graph — programs · calls · includes · variables · DB tables
</text>
</g>
<!-- 4 arrows fanning out to query boxes -->
<line x1="132" y1="166" x2="102" y2="202" marker-end="url(#arrow)"
<line x1="171" y1="166" x2="132" y2="202" marker-end="url(#arrow)"
style="fill:none;stroke:rgb(115,114,108);stroke-width:1.5px;"/>
<line x1="250" y1="166" x2="278" y2="202" marker-end="url(#arrow)"
<line x1="343" y1="166" x2="330" y2="202" marker-end="url(#arrow)"
style="fill:none;stroke:rgb(115,114,108);stroke-width:1.5px;"/>
<line x1="430" y1="166" x2="402" y2="202" marker-end="url(#arrow)"
<line x1="515" y1="166" x2="528" y2="202" marker-end="url(#arrow)"
style="fill:none;stroke:rgb(115,114,108);stroke-width:1.5px;"/>
<line x1="548" y1="166" x2="578" y2="202" marker-end="url(#arrow)"
<line x1="688" y1="166" x2="726" y2="202" marker-end="url(#arrow)"
style="fill:none;stroke:rgb(115,114,108);stroke-width:1.5px;"/>
<!-- section label -->
<text x="340" y="198" text-anchor="middle"
<text x="430" y="198" text-anchor="middle"
style="fill:rgb(61,61,58);font-family:'Anthropic Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:12px;">
Query phases
</text>
<!-- ── 1. Orient ── -->
<g>
<rect x="60" y="206" width="148" height="90" rx="8" stroke-width="0.5"
<rect x="40" y="206" width="184" height="90" rx="8" stroke-width="0.5"
style="fill:rgb(230,241,251);stroke:rgb(24,95,165);"/>
<text x="134" y="224" text-anchor="middle" dominant-baseline="central"
<text x="132" y="224" text-anchor="middle" dominant-baseline="central"
style="fill:rgb(12,68,124);font-family:'Anthropic Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:13px;font-weight:500;">
Orient
</text>
<text x="134" y="242" text-anchor="middle" dominant-baseline="central"
<text x="132" y="242" text-anchor="middle" dominant-baseline="central"
style="fill:rgb(24,95,165);font-family:'Anthropic Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:10.5px;">
What modules exist?
</text>
<text x="134" y="258" text-anchor="middle" dominant-baseline="central"
<text x="132" y="258" text-anchor="middle" dominant-baseline="central"
style="fill:rgb(24,95,165);font-family:'Anthropic Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:10.5px;">
What does X do?
</text>
<text x="134" y="276" text-anchor="middle" dominant-baseline="central"
<text x="132" y="276" text-anchor="middle" dominant-baseline="central"
style="fill:rgb(83,74,183);font-family:'Anthropic Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:10px;">
/modules · /context · /digest
</text>
@@ -99,21 +99,21 @@
<!-- ── 2. Call Graph ── -->
<g>
<rect x="220" y="206" width="148" height="90" rx="8" stroke-width="0.5"
<rect x="238" y="206" width="184" height="90" rx="8" stroke-width="0.5"
style="fill:rgb(250,238,218);stroke:rgb(133,79,11);"/>
<text x="294" y="224" text-anchor="middle" dominant-baseline="central"
<text x="330" y="224" text-anchor="middle" dominant-baseline="central"
style="fill:rgb(99,56,6);font-family:'Anthropic Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:13px;font-weight:500;">
Call Graph
</text>
<text x="294" y="242" text-anchor="middle" dominant-baseline="central"
<text x="330" y="242" text-anchor="middle" dominant-baseline="central"
style="fill:rgb(133,79,11);font-family:'Anthropic Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:10.5px;">
Who calls X? What calls Y?
</text>
<text x="294" y="258" text-anchor="middle" dominant-baseline="central"
<text x="330" y="258" text-anchor="middle" dominant-baseline="central"
style="fill:rgb(133,79,11);font-family:'Anthropic Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:10.5px;">
How far does it reach?
</text>
<text x="294" y="276" text-anchor="middle" dominant-baseline="central"
<text x="330" y="276" text-anchor="middle" dominant-baseline="central"
style="fill:rgb(133,79,11);font-family:'Anthropic Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:10px;">
/callers · /callees · /call-tree
</text>
@@ -121,21 +121,21 @@
<!-- ── 3. Data & DB ── -->
<g>
<rect x="380" y="206" width="148" height="90" rx="8" stroke-width="0.5"
<rect x="436" y="206" width="184" height="90" rx="8" stroke-width="0.5"
style="fill:rgb(250,238,218);stroke:rgb(133,79,11);"/>
<text x="454" y="224" text-anchor="middle" dominant-baseline="central"
<text x="528" y="224" text-anchor="middle" dominant-baseline="central"
style="fill:rgb(99,56,6);font-family:'Anthropic Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:13px;font-weight:500;">
Data &amp; DB
</text>
<text x="454" y="242" text-anchor="middle" dominant-baseline="central"
<text x="528" y="242" text-anchor="middle" dominant-baseline="central"
style="fill:rgb(133,79,11);font-family:'Anthropic Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:10.5px;">
Which tables? What SQL?
</text>
<text x="454" y="258" text-anchor="middle" dominant-baseline="central"
<text x="528" y="258" text-anchor="middle" dominant-baseline="central"
style="fill:rgb(133,79,11);font-family:'Anthropic Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:10.5px;">
Which data areas? Fields?
</text>
<text x="454" y="276" text-anchor="middle" dominant-baseline="central"
<text x="528" y="276" text-anchor="middle" dominant-baseline="central"
style="fill:rgb(133,79,11);font-family:'Anthropic Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:10px;">
/db-accesses · /sql-statements
</text>
@@ -143,56 +143,56 @@
<!-- ── 4. Deep Dive ── -->
<g>
<rect x="540" y="206" width="148" height="90" rx="8" stroke-width="0.5"
<rect x="634" y="206" width="184" height="90" rx="8" stroke-width="0.5"
style="fill:rgb(250,236,231);stroke:rgb(153,60,29);"/>
<text x="614" y="224" text-anchor="middle" dominant-baseline="central"
<text x="726" y="224" text-anchor="middle" dominant-baseline="central"
style="fill:rgb(113,43,19);font-family:'Anthropic Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:13px;font-weight:500;">
Deep Dive
</text>
<text x="614" y="242" text-anchor="middle" dominant-baseline="central"
<text x="726" y="242" text-anchor="middle" dominant-baseline="central"
style="fill:rgb(153,60,29);font-family:'Anthropic Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:10.5px;">
Where does a value come from?
</text>
<text x="614" y="258" text-anchor="middle" dominant-baseline="central"
<text x="726" y="258" text-anchor="middle" dominant-baseline="central"
style="fill:rgb(153,60,29);font-family:'Anthropic Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:10.5px;">
Dispatch? Source lines?
</text>
<text x="614" y="276" text-anchor="middle" dominant-baseline="central"
<text x="726" y="276" text-anchor="middle" dominant-baseline="central"
style="fill:rgb(153,60,29);font-family:'Anthropic Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:10px;">
/flow-forward · /dispatch-table
</text>
</g>
<!-- 4 arrows converging to output -->
<line x1="134" y1="296" x2="220" y2="342" marker-end="url(#arrow)"
<line x1="132" y1="296" x2="376" y2="342" marker-end="url(#arrow)"
style="fill:none;stroke:rgb(115,114,108);stroke-width:1.5px;"/>
<line x1="294" y1="296" x2="300" y2="342" marker-end="url(#arrow)"
<line x1="330" y1="296" x2="412" y2="342" marker-end="url(#arrow)"
style="fill:none;stroke:rgb(115,114,108);stroke-width:1.5px;"/>
<line x1="454" y1="296" x2="390" y2="342" marker-end="url(#arrow)"
<line x1="528" y1="296" x2="448" y2="342" marker-end="url(#arrow)"
style="fill:none;stroke:rgb(115,114,108);stroke-width:1.5px;"/>
<line x1="614" y1="296" x2="468" y2="342" marker-end="url(#arrow)"
<line x1="726" y1="296" x2="483" y2="342" marker-end="url(#arrow)"
style="fill:none;stroke:rgb(115,114,108);stroke-width:1.5px;"/>
<!-- ── Output ── -->
<text x="340" y="338" text-anchor="middle"
<text x="430" y="338" text-anchor="middle"
style="fill:rgb(61,61,58);font-family:'Anthropic Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:12px;">
Output
</text>
<g>
<rect x="120" y="346" width="440" height="56" rx="8" stroke-width="0.5"
<rect x="150" y="346" width="560" height="56" rx="8" stroke-width="0.5"
style="fill:rgb(251,234,240);stroke:rgb(153,53,86);"/>
<text x="340" y="366" text-anchor="middle" dominant-baseline="central"
<text x="430" y="366" text-anchor="middle" dominant-baseline="central"
style="fill:rgb(114,36,62);font-family:'Anthropic Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:14px;font-weight:500;">
AI Agent produces an answer
</text>
<text x="340" y="386" text-anchor="middle" dominant-baseline="central"
<text x="430" y="386" text-anchor="middle" dominant-baseline="central"
style="fill:rgb(153,53,86);font-family:'Anthropic Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:12px;">
Analysis · Impact assessment · Generated code (Spring Boot · Panache · @Entity)
</text>
</g>
<!-- footnote -->
<text x="340" y="426" text-anchor="middle"
<text x="430" y="426" text-anchor="middle"
style="fill:rgb(140,139,133);font-family:'Anthropic Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:11px;">
The agent reads only. Natural + Java sources are ingested once into Neo4j before querying.
</text>

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -649,6 +649,33 @@ items). Each entry records what was built; IDs are preserved from the roadmap
`db-accesses` variant), default `limit=50`, `offset=0`. CLI gains
`--limit`/`--offset`. New IT `calleesPaginationLimitsAndOffsets`.
## Integration & ops
- [x] **5. MCP endpoint (HTTP/SSE)** (2026-07-07) — implemented the previously
empty `ac-code-server/.../mcp` package as a Quarkus MCP server
(`io.quarkiverse.mcp:quarkus-mcp-server-sse` 1.9.1, augments cleanly against
Quarkus 3.36.2), exposing the API to agents over HTTP/SSE at `/mcp/sse` (server
name `agenticcode`). Two `@ApplicationScoped` tool beans mirror the REST surface
by delegating to the same `GraphRepository`/`ProjectIngestService` — no query
logic duplicated. **`McpQueryTools`** (22 read tools, all `@Blocking`, returning
the same concise JSON as REST via `McpSupport`): `list_projects`, `list_modules`,
`module_digest`, `module_context`, `module_functions`, `module_data_structures`,
`module_dispatch_table`, `module_columns`, `callers`, `callees`, `call_tree`,
`db_accesses`, `sql_statements`, `data_structure_fields`, `db_table_columns`,
`search_identifier`, `search_value`, `variable_reads`, `variable_writes`,
`flow_forward`, `flow_backward`, `field_flow`. **`McpIngestTools`**: `ingest_all`,
`ingest_module`, `ingest_call_graph`. Deliberately **read-only + ingest** —
destructive project create/update/delete/clearAll are not exposed. Errors reuse
the REST `{error,code,details}` shape as MCP tool errors (`PROJECT_NOT_FOUND`,
`ROOT_NOT_SET`, `INVALID_TYPE`, …); the deep-query tools return the same
deep-ingest hint (pointing at `ingest_module`) when a module isn't deep-ingested.
Extracted the project-root guard shared with REST into `ProjectRootResolver` so
the validation can't drift between transports. New `McpToolsIT` drives the whole
pipeline through tools only (connect → `ingest_all` → `list_modules`/`list_projects`
+ a `PROJECT_NOT_FOUND` error case); all 58 REST ITs still green after the
resolver refactor. Docs: `agent-module-analysis.md` gained an "Access via MCP"
section.
## Tests & tooling
- [x] **8. `ac-cli` test coverage** (2026-06-17) — module had zero tests. Added

View File

@@ -16,6 +16,64 @@ tracks only items that are still open.
Split out from the duplicate-detection fix (which branches on the in-memory
`SourceFiles.Kind` at ingest time and needed none of this).
## Java analysis fidelity (graph modeling)
Motivated by a batch-job analysis pass over the PUR `pur-batch` module
(2026-07-06): 9 concrete `AbstractPurBatchJob` subclasses. The graph confirmed
findings at the *step-class* level, but three structural gaps forced the work to
be **source-driven rather than graph-driven**. Root cause: the Java graph models
only direct method/constructor calls, not the framework/DI/JPA indirection PUR
actually runs on. Ordered by analytical impact.
- [ ] **J1. JPA/Panache repository calls as DB accesses (Java)** (found 2026-07-06)
— `db-accesses`/`sql-statements` are empty for Java, so "which tables does this
job touch?" is unanswerable from the graph. The real operations
(`deleteByClient`, `save`, `merge`, `clearRiskTable`, `createOrUpdate`) live
inside repository/logic classes and surface only as a callee *class*; table
names had to be read from the entity `TABLE_NAME` constant. → At ingest, map
repository method calls to READS/WRITES on the entity's `@Table`: `save/persist/
merge` → WRITE, `delete*` → DELETE, `find*/select*/getBy*` → READ; parse derived
query-method names (`findAllByClient` → READ + filter) and `@Query`/JPQL/native
SQL strings into table + mode. Biggest single lever — makes Java `db-accesses`
as useful as Natural's. Complements the entity schema already exposed via
`/columns`.
- [ ] **J2. DI + class-literal edges (Java)** (found 2026-07-06) — `call-tree` on
a job class (e.g. `AccountKeyImportJob`) returns **empty**, because JBeret steps
are wired via class literals (`super(AccountKeyInitStep.class, …)`) and CDI
injection — neither is a method call, so no edge exists. → Add `INJECTS` edges
from `@Inject`/constructor-injected fields to the concrete bean type, and a
`REFERENCES` edge for a class literal (`X.class`) passed as an argument.
Optionally a framework-binding pass that recognises `batchlet(refName(X.class))`
as a job→step edge. Reconnects the job to its steps and collaborators.
- [ ] **J3. Interface → implementation resolution (Java)** (found 2026-07-06) —
callees show `IRiskRepository` *and* `RiskRepository` as separate nodes;
traversal dead-ends at the interface. → Add an `IMPLEMENTED_BY` edge (interface
→ concrete `@ApplicationScoped`/`@Named` bean) and a `?resolveInterfaces=true`
option on `call-tree`/`callees` that auto-hops to the impl (deterministic when a
single implementation exists). Builds naturally on the per-language module-kind
item above (needs interface-vs-class sub-kind).
- [ ] **J4. Virtual/override (template-method) dispatch (Java)** (found 2026-07-06)
— the concrete steps' real logic runs through the abstract base
(`AbstractSteuertabellenProcessingStep.doProcessItem` calls the overridden
`clearTable`/`writeEntities`). The `EXTENDS` edge exists, but the call
base-method → concrete override is not linked, so call-tree stops at the base.
→ For a call to an abstract/super method, add `OVERRIDDEN_BY` edges to all known
subclass overrides (CHA/RTA-style resolution).
- [ ] **J5. Cross-class dataflow (Java)** (found 2026-07-06) —
`flow-forward`/`flow-backward` are intra-class only for Java. → Once J2/J3
resolve cross-class call edges, extend argument→parameter mapping across those
edges (depth-bounded), matching the Natural reach.
- [ ] **J6. Ingest noise: JDK/framework allowlist** (found 2026-07-06) — ingesting
one job pulled **1158 files** and an `unresolved` list dominated by JDK types
(`LIST`, `STRING`, `OPTIONAL`, `HASHMAP`, …). → Filter JDK/stdlib + known
frameworks via an allowlist and exclude `target/`-generated duplicates, so
`unresolved` surfaces only genuine gaps.
## Token efficiency (payload shape)
- [x] **P1-t. `/context` is heavy by default — make sub-arrays opt-in, return counts** (done 2026-06-22)
@@ -77,12 +135,6 @@ tracks only items that are still open.
## Integration & ops
- [ ] **5. MCP endpoint** — implement `ac-code-server/.../mcp` (currently empty
`package-info.java` only). Highest strategic value: native Claude
Code / agent tool-use integration per the ADR. Expose the same operations as
the REST API (`callers`, `callees`, `db-accesses`, `search-identifier`,
`ingest`, project CRUD) as MCP tools.
- [ ] **9. `docker-compose.yml`** — untracked file at repo root provides a
dev Neo4j container. Either commit it and reference it from the README's
"Getting Started" (replacing/augmenting the manual `docker run` command), or