MCP
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
13
pom.xml
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 & 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 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user