Remove MCP

This commit is contained in:
Ingo Schnabel
2026-08-04 12:57:08 +02:00
parent 3db477f4a3
commit 2c56eea161
25 changed files with 149 additions and 1370 deletions

View File

@@ -1,8 +0,0 @@
{
"mcpServers": {
"agenticcode": {
"type": "sse",
"url": "http://localhost:8787/mcp/sse"
}
}
}

View File

@@ -24,10 +24,9 @@
* **A deep refresh is long and mutates the graph.** Do not abort one halfway: the earlier enrichment steps * **A deep refresh is long and mutates the graph.** Do not abort one halfway: the earlier enrichment steps
are already committed, so the graph is left partially updated and every later query silently answers from are already committed, so the graph is left partially updated and every later query silently answers from
it. If one must be stopped, say plainly that the graph is now in a half-updated state. it. If one must be stopped, say plainly that the graph is now in a half-updated state.
* **Keep REST API, MCP, and `ac-cli` in sync.** Every REST endpoint added or changed in * **Keep the REST API and `ac-cli` in sync.** Every REST endpoint added or changed in
`ac-code-server/.../api/*Resource.java` MUST have a matching MCP tool in `.../mcp/Mcp*Tools.java` **and** a matching `ac-code-server/.../api/*Resource.java` MUST have a matching `ac-cli` command, delivered in the same change. Treat
`ac-cli` command, delivered in the same change. Treat REST + MCP + CLI as one unit of work — none of the three is REST + CLI as one unit of work — neither is "done" without the other.
"done" without the other two.
## 1. Core Rules ## 1. Core Rules
- Never assume. Always read files first with tools. - Never assume. Always read files first with tools.
@@ -57,31 +56,29 @@
output. Usage guide: `x-docs/mcp-api-usage-ac-implementation.md`. Re-ingest after code changes output. Usage guide: `x-docs/mcp-api-usage-ac-implementation.md`. Re-ingest after code changes
(`ac refresh` / `POST /api/projects/ac/refresh`, or `--deep`/`?deep=true` for a full field-level (`ac refresh` / `POST /api/projects/ac/refresh`, or `--deep`/`?deep=true` for a full field-level
pass) before trusting query results. pass) before trusting query results.
- **Tool priority: MCP first, then REST API, then the `ac` CLI, then grep/Explore.** Try the - **Tool priority: REST API first, then the `ac` CLI, then grep/Explore.** Query the REST endpoints
`mcp__agenticcode__*` MCP tools before anything else. If an MCP call fails or misbehaves (e.g. a (`GET /api/projects/ac/modules/{name}/...`) before anything else. The `ac <command>` CLI (`ac callers`,
session/protocol error), fall back to the equivalent REST endpoint `ac callees`,
(`GET /api/projects/ac/modules/{name}/...`) rather than abandoning the API — don't let one broken
MCP call push you straight to grep. The `ac <command>` CLI (`ac callers`, `ac callees`,
`ac call-tree`, `ac context`, `ac db-accesses`, `ac refresh`, ...) is a convenience wrapper over the same REST API, `ac call-tree`, `ac context`, `ac db-accesses`, `ac refresh`, ...) is a convenience wrapper over the same REST API,
useful for quick manual checks. **If the server is unavailable** (`http://localhost:8787` useful for quick manual checks. **If the server is unavailable** (`http://localhost:8787`
unreachable), **ask the user to run `./manage-ac.sh deploy`** — never start it yourself (see Hard Rules). unreachable), **ask the user to run `./manage-ac.sh deploy`** — never start it yourself (see Hard Rules).
**Only when it still can't answer the question** (info the API doesn't expose, or the analysis needs **Only when it still can't answer the question** (info the API doesn't expose, or the analysis needs
exact source text/comments/formatting) fall back to normal code analysis (Read/Grep/Explore agent) exact source text/comments/formatting) fall back to normal code analysis (Read/Grep/Explore agent)
exactly as you would on any other codebase. exactly as you would on any other codebase.
- **If a needed capability is missing from MCP/API/CLI** (not just unreachable, but the question is - **If a needed capability is missing from the API/CLI** (not just unreachable, but the question is
one none of them can answer at all), don't silently fall back to grep and move on — after finishing one none of them can answer at all), don't silently fall back to grep and move on — after finishing
the task with the grep-based fallback, use `AskUserQuestion` to tell the user what was missing and the task with the grep-based fallback, use `AskUserQuestion` to tell the user what was missing and
ask whether it should be added as a feature in `x-docs/roadmap.md`. ask whether it should be added as a feature in `x-docs/roadmap.md`.
## 2. Mandatory 4-Phase Workflow ## 2. Mandatory 4-Phase Workflow
0. **Clarify Intent** -Assess if the user's request is fully clear. ``0. **Clarify Intent** -Assess if the user's request is fully clear.
- **Clear**: State your understanding and proceed. - **Clear**: State your understanding and proceed.
- **Unclear**: Use `AskUserQuestion` tool with concrete options. Repeat until unambiguous. - **Unclear**: Use `AskUserQuestion` tool with concrete options. Repeat until unambiguous.
1. **PROPOSAL** – Clear plan + impact. Wait for `OK PROPOSAL`. 1. **PROPOSAL** – Clear plan + impact. Wait for `OK PROPOSAL`.
2. **VALIDATE** – Brutal self-critique (NullAway, layering, regressions). Wait for `OK VALIDATE`. 2. **VALIDATE** – Brutal self-critique (NullAway, layering, regressions). Wait for `OK VALIDATE`.
3. **IMPLEMENT** – One unit at a time. Show exact diff only. 3. **IMPLEMENT** – One unit at a time. Show exact diff only.
4. **VERIFICATION** – Run relevant tests. Suggest a commit message, but do not commit unless asked (see Core Rules). 4. **VERIFICATION** – Run relevant tests. Suggest a commit message, but do not commit unless asked (see Core Rules).``
Quarkus-based server for parsing, storing, and agentically querying source code (Natural/Software AG and Java). Quarkus-based server for parsing, storing, and agentically querying source code (Natural/Software AG and Java).
Programs are parsed into a unified AST, persisted as a graph in Neo4j, enriched with semantic information, and exposed via an agent-optimized API. Programs are parsed into a unified AST, persisted as a graph in Neo4j, enriched with semantic information, and exposed via an agent-optimized API.
@@ -89,14 +86,14 @@ Programs are parsed into a unified AST, persisted as a graph in Neo4j, enriched
## Architecture Overview ## Architecture Overview
``` ```
Parser Layer → Unified AST → Neo4j Graph DB → Enrichment → Agentic API (REST + MCP) Parser Layer → Unified AST → Neo4j Graph DB → Enrichment → Agentic API (REST)
``` ```
- **Natural Parser**: custom implementation (no OSS parser available for Software AG Natural) - **Natural Parser**: custom implementation (no OSS parser available for Software AG Natural)
- **Java Parser**: JavaParser library - **Java Parser**: JavaParser library
- **Graph Store**: Neo4j — nodes for modules, functions, variables, data structures, DB accesses - **Graph Store**: Neo4j — nodes for modules, functions, variables, data structures, DB accesses
- **Enrichment**: call graph, identifier index, data structures, ADABAS/SQL access patterns - **Enrichment**: call graph, identifier index, data structures, ADABAS/SQL access patterns
- **API**: Quarkus REST (JAX-RS) + MCP endpoint, optimized for AI agent tool use - **API**: Quarkus REST (JAX-RS), optimized for AI agent tool use
## Tech Stack ## Tech Stack
@@ -123,8 +120,7 @@ agenticcode/
│ ├── src/main/java/de/agenticcode/ │ ├── src/main/java/de/agenticcode/
│ │ ├── api/ # JAX-RS Resources │ │ ├── api/ # JAX-RS Resources
│ │ ├── service/ # Business logic │ │ ├── service/ # Business logic
│ │ ├── enrichment/ # Enrichment pipelines │ │ └── enrichment/ # Enrichment pipelines
│ │ └── mcp/ # MCP tool endpoints
│ └── src/main/resources/ │ └── src/main/resources/
│ └── application.properties │ └── application.properties
├── ac-parser-natural/ # Natural parser module ├── ac-parser-natural/ # Natural parser module
@@ -236,7 +232,7 @@ The API is designed primarily for AI agent tool use:
- `GET /api/search/identifier?name=...` — find an identifier across all modules - `GET /api/search/identifier?name=...` — find an identifier across all modules
- `POST /api/ingest` — parse and ingest source code into the graph - `POST /api/ingest` — parse and ingest source code into the graph
MCP endpoint structure: `@docs/api-endpoints.md` Endpoint structure: `@docs/api-endpoints.md`
## Natural Parser: Key Constructs ## Natural Parser: Key Constructs
@@ -297,5 +293,4 @@ mvn test -Dtest="*IT"
| Multi-module Maven | Parsers are independently testable and replaceable | | Multi-module Maven | Parsers are independently testable and replaceable |
| Custom Natural parser | No OSS parser available for Software AG Natural | | Custom Natural parser | No OSS parser available for Software AG Natural |
| JavaParser over tree-sitter | Mature Java library, type-safe AST API | | JavaParser over tree-sitter | Mature Java library, type-safe AST API |
| MCP alongside REST | Native integration with Claude Code and other AI agents |

View File

@@ -4,7 +4,7 @@
AgenticCode ingests source code (Natural/Software AG and Java), parses it into a unified AST, persists it in Neo4j, AgenticCode ingests source code (Natural/Software AG and Java), parses it into a unified AST, persists it in Neo4j,
enriches it with semantic information (call graphs, DB accesses, data structures, dynamic-dispatch resolution), and enriches it with semantic information (call graphs, DB accesses, data structures, dynamic-dispatch resolution), and
exposes everything through an agent-ready **REST + MCP** API, a **CLI**, and a **web UI**. exposes everything through an agent-ready **REST** API, a **CLI**, and a **web UI**.
--- ---
@@ -37,12 +37,11 @@ flowchart TD
end end
subgraph API["Agentic API — Quarkus"] subgraph API["Agentic API — Quarkus"]
REST["REST API\nJAX-RS · RESTEasy Reactive"] REST["REST API\nJAX-RS · RESTEasy Reactive · tool-use optimized"]
MCP["MCP Endpoint\nHTTP/SSE · tool-use optimized"]
end end
subgraph Clients["Clients"] subgraph Clients["Clients"]
AG(["Claude / LLM Agent\nvia MCP"]) AG(["Claude / LLM Agent\nvia REST"])
CLI(["ac CLI"]) CLI(["ac CLI"])
UI(["Web UI\nReact + Vite"]) UI(["Web UI\nReact + Vite"])
end end
@@ -56,9 +55,7 @@ flowchart TD
PI --> NEO PI --> NEO
NEO --> CG & DB & ID & DS NEO --> CG & DB & ID & DS
CG & DB & ID & DS --> REST CG & DB & ID & DS --> REST
CG & DB & ID & DS --> MCP
REST --> AG & CLI & UI REST --> AG & CLI & UI
MCP --> AG
``` ```
--- ---
@@ -85,7 +82,6 @@ Once up:
- **Web UI** — `http://localhost:5174` - **Web UI** — `http://localhost:5174`
- **REST API** — `http://localhost:8787/api` - **REST API** — `http://localhost:8787/api`
- **MCP endpoint** — `http://localhost:8787/mcp/sse` (HTTP/SSE transport)
- **OpenAPI / health** — `http://localhost:8787/q/openapi`, `http://localhost:8787/q/health` - **OpenAPI / health** — `http://localhost:8787/q/openapi`, `http://localhost:8787/q/health`
- **Neo4j browser** — `http://localhost:7474` (credentials `neo4j` / `agenticcode`) - **Neo4j browser** — `http://localhost:7474` (credentials `neo4j` / `agenticcode`)
@@ -241,19 +237,14 @@ stays visible as an unresolved site.
--- ---
## MCP (Claude Code & other agents) ## Agent usage (Claude Code & other agents)
The server exposes every query capability as MCP tools over HTTP/SSE at `http://localhost:8787/mcp/sse` (server name The server exposes every query capability as REST endpoints under `http://localhost:8787/api` — e.g.
`agenticcode`). Register it with Claude Code: `/projects`, `/projects/{p}/modules`, `.../callers`, `.../callees`, `.../call-tree`, `.../db-accesses`,
`/search/identifier`, `.../context`, `.../payload`, `.../dispatch-table`, `/variables/{n}/flow-forward` and
```bash `/flow-backward`, `/variables/{n}/field-flow`, `/variables/{n}/reads` and `/writes`,
claude mcp add --transport sse agenticcode http://localhost:8787/mcp/sse `/dynamic-calls/unresolved`, `/dynamic-calls/overrides`, `/refresh`, and more. The OpenAPI spec is served at
``` `/q/openapi`. A full usage guide with response fields and semantics lives in [
Tools mirror the REST surface — e.g. `list_projects`, `list_modules`, `callers`, `callees`, `call_tree`, `db_accesses`,
`search_identifier`, `module_context`, `module_payload`, `module_dispatch_table`, `flow_forward`/`flow_backward`,
`field_flow`, `variable_reads`/`variable_writes`, `list_unresolved_dynamic_calls`, `set_dynamic_call_override`,
`refresh`, and more. A full usage guide with response fields and semantics lives in [
`x-docs/mcp-api-usage-ac-implementation.md`](x-docs/mcp-api-usage-ac-implementation.md). `x-docs/mcp-api-usage-ac-implementation.md`](x-docs/mcp-api-usage-ac-implementation.md).
--- ---
@@ -416,7 +407,7 @@ own-subroutine `PERFORM` wiring). `search-identifier` takes `--type`, `--module`
names, a leading sigil (`#`, `&`, `+`) is optional — `ac search-identifier I-LINE-LEV` and `'#I-LINE-LEV'` are names, a leading sigil (`#`, `&`, `+`) is optional — `ac search-identifier I-LINE-LEV` and `'#I-LINE-LEV'` are
equivalent. equivalent.
Everything the CLI does is also available as a REST endpoint and an MCP tool — the three are kept in lockstep. Everything the CLI does is also available as a REST endpoint — the two are kept in lockstep.
--- ---
@@ -428,7 +419,6 @@ Everything the CLI does is also available as a REST endpoint and an MCP tool —
| Language | Java 21 — Records, Sealed Classes, Virtual Threads | | Language | Java 21 — Records, Sealed Classes, Virtual Threads |
| Graph DB | Neo4j 5.x via `neo4j-java-driver` | | Graph DB | Neo4j 5.x via `neo4j-java-driver` |
| REST | RESTEasy Reactive (JAX-RS) | | REST | RESTEasy Reactive (JAX-RS) |
| MCP | `quarkus-mcp-server-sse` (HTTP/SSE) |
| Natural Parser | Custom implementation | | Natural Parser | Custom implementation |
| Java Parser | [JavaParser](https://javaparser.org) | | Java Parser | [JavaParser](https://javaparser.org) |
| CLI | Picocli uber-jar | | CLI | Picocli uber-jar |
@@ -486,7 +476,7 @@ Full schema with Cypher examples: [`x-docs/ast-graph-schema.md`](x-docs/ast-grap
``` ```
agenticcode/ agenticcode/
├── ac-code-server/ # Quarkus application (REST + MCP) ├── ac-code-server/ # Quarkus application (REST API)
├── ac-cli/ # Picocli command-line client (`ac`) ├── ac-cli/ # Picocli command-line client (`ac`)
├── ac-ui/ # React + Vite web UI ├── ac-ui/ # React + Vite web UI
├── ac-parser-core/ # Shared AST model + LanguageParser SPI ├── ac-parser-core/ # Shared AST model + LanguageParser SPI

View File

@@ -4,4 +4,4 @@
server.url=http://localhost:8787 server.url=http://localhost:8787
# Stamped by manage-ac.sh (stamp_cli_version) from ac-code-server's agenticcode.version # Stamped by manage-ac.sh (stamp_cli_version) from ac-code-server's agenticcode.version
# at build time. "dev" means this jar wasn't built via manage-ac.sh. # at build time. "dev" means this jar wasn't built via manage-ac.sh.
version=141 version=151

View File

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

View File

@@ -1,80 +0,0 @@
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 {@code refresh} tool mirroring the {@code refresh} endpoints of {@code AnalysisResource}
* (roadmap item 42). 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} refresh and
* returns the {@link IngestSummary} as JSON.
*
* <p>{@link Blocking}: refresh walks the file system and persists to Neo4j. The eager
* {@code ingest_all}/{@code ingest_module}/{@code ingest_call_graph} tools were removed — projects
* are coarse-scanned on create and deep-ingested lazily per query, with {@code refresh} as the
* explicit (re-)ingest. Destructive project CRUD (create/update/delete/clearAll) is intentionally
* <em>not</em> exposed.
*/
@ApplicationScoped
@McpLogged
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 = "refresh", description = "Re-ingest a project from disk so the graph reflects the current files (also the remedy for a STALE_SOURCE read). With 'module', deep re-ingests that module and its dependency tree (bounded by maxDepth 1..20 and maxNodes). Without 'module', re-ingests the whole root: the call graph by default, or full field-level dataflow with deep=true.")
@Blocking
public ToolResponse refresh(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module name to deep-refresh; omit to refresh the whole project", required = false) @Nullable String module,
@ToolArg(description = "Whole-project refresh only: deep (field-level) ingest", required = false) @Nullable Boolean deep,
@ToolArg(description = "Module refresh only: max dependency depth in hops (1..20); default server setting", required = false) @Nullable Integer maxDepth,
@ToolArg(description = "Module refresh only: max number of files to ingest; default server setting", required = false) @Nullable Integer maxNodes) {
if (module != null && !module.isBlank()) {
return run(project, info -> ingestService.refreshModule(info, module, maxDepth, maxNodes));
}
boolean deepIngest = deep != null && deep;
return run(project, info -> ingestService.refreshProject(info, deepIngest));
}
/**
* 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

@@ -1,18 +0,0 @@
package com.agenticcode.codeserver.mcp;
import jakarta.interceptor.InterceptorBinding;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Binds {@link McpLoggingInterceptor} to every {@code @Tool} method of the annotated class, so each
* MCP call is logged with its tool name and arguments.
*/
@InterceptorBinding
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface McpLogged {
}

View File

@@ -1,43 +0,0 @@
package com.agenticcode.codeserver.mcp;
import io.quarkiverse.mcp.server.Tool;
import jakarta.annotation.Priority;
import jakarta.interceptor.AroundInvoke;
import jakarta.interceptor.Interceptor;
import jakarta.interceptor.InvocationContext;
import org.jboss.logging.Logger;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
/**
* Logs every MCP tool call ({@code @McpLogged}-annotated classes) with its tool name (from
* {@code @Tool.name()}) and argument values, so tool usage is visible in the server log the same
* way REST calls are visible via the access log.
*/
@McpLogged
@Interceptor
@Priority(Interceptor.Priority.APPLICATION)
public class McpLoggingInterceptor {
private static final Logger LOG = Logger.getLogger("com.agenticcode.mcp");
private static String formatArgs(Method method, Object[] args) {
Parameter[] params = method.getParameters();
return IntStream.range(0, args.length)
.mapToObj(i -> params[i].getName() + "=" + args[i])
.collect(Collectors.joining(", "));
}
@AroundInvoke
Object logCall(InvocationContext context) throws Exception {
Method method = context.getMethod();
Tool tool = method.getAnnotation(Tool.class);
if (tool != null) {
LOG.infof("MCP tool call: %s(%s)", tool.name(), formatArgs(method, context.getParameters()));
}
return context.proceed();
}
}

View File

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

View File

@@ -1,57 +0,0 @@
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

@@ -1,5 +0,0 @@
/**
* MCP tool endpoints for AI agent integration.
*/
@org.jspecify.annotations.NullMarked
package com.agenticcode.codeserver.mcp;

View File

@@ -10,10 +10,10 @@ import java.nio.file.Path;
/** /**
* Loads a project and validates that its root folder is usable for ingest. * 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 * <p>Shared by the REST resources so the guard logic
* (project exists, root configured, root is a directory on the server) has a single source of * (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 * 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). * {@code code}; each caller maps it to its own error shape (HTTP status).
*/ */
@ApplicationScoped @ApplicationScoped
public class ProjectRootResolver { public class ProjectRootResolver {

View File

@@ -8,8 +8,8 @@ import org.eclipse.microprofile.config.inject.ConfigProperty;
* Single source of truth for the server's product name/version — {@code version} is * Single source of truth for the server's product name/version — {@code version} is
* {@code agenticcode.version} (a manually-bumped release counter in {@code application.properties}, * {@code agenticcode.version} (a manually-bumped release counter in {@code application.properties},
* deliberately independent of the Maven project version). Shared by the startup log line * deliberately independent of the Maven project version). Shared by the startup log line
* ({@link VersionLogger}), the REST {@code GET /api/version} endpoint, and the MCP {@code version} * ({@link VersionLogger}) and the REST {@code GET /api/version} endpoint (both reference
* tool/server-info (all reference {@code agenticcode.version} rather than duplicating it). * {@code agenticcode.version} rather than duplicating it).
*/ */
@Singleton @Singleton
public record VersionInfo(String name, String version) { public record VersionInfo(String name, String version) {

View File

@@ -1,13 +1,9 @@
# HTTP server port # HTTP server port
quarkus.http.port=8787 quarkus.http.port=8787
# AgenticCode's own release counter (not the Maven project version) — bump this by hand for each # AgenticCode's own release counter (not the Maven project version) — bump this by hand for each
# release. Single source of truth for the startup log line, GET /api/version, and the MCP # release. Single source of truth for the startup log line, GET /api/version, and the OpenAPI
# 'version' tool/server-info (referenced below via property expression, not duplicated). # info version (referenced below via property expression, not duplicated).
agenticcode.version=141 agenticcode.version=151
# 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=${agenticcode.version}
# OpenAPI / Swagger UI (item 48) — the generated spec is the contract the web-UI TS client # OpenAPI / Swagger UI (item 48) — the generated spec is the contract the web-UI TS client
# is generated against. Served at /q/openapi (yaml/json); Swagger UI at /q/swagger-ui in dev. # is generated against. Served at /q/openapi (yaml/json); Swagger UI at /q/swagger-ui in dev.
mp.openapi.extensions.smallrye.info.title=AgenticCode API mp.openapi.extensions.smallrye.info.title=AgenticCode API

View File

@@ -1,145 +0,0 @@
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 refresh} 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, "natural", null, 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("version", "list_projects", "list_modules", "callers", "callees",
"call_tree", "db_accesses", "search_identifier", "refresh")) {
assertNotNull(page.findByName(tool), "tool not registered: " + tool);
}
})
.thenAssertResults();
client.disconnect();
}
@Test
void versionToolReturnsNameAndVersion() {
McpSseTestClient client = connect();
client.when()
.toolsCall("version", Map.of(), response -> {
assertFalse(response.isError(), text(response));
assertTrue(text(response).contains("\"name\":\"agenticcode\""), text(response));
})
.thenAssertResults();
client.disconnect();
}
@Test
void ingestAndQueryThroughTools() {
McpSseTestClient client = connect();
// Ingest the whole project root via the MCP refresh tool.
client.when()
.toolsCall("refresh", Map.of("project", PROJECT, "deep", true), response -> {
assertFalse(response.isError(), "refresh 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,7 +30,6 @@
<quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id> <quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
<quarkus.platform.version>3.36.2</quarkus.platform.version> <quarkus.platform.version>3.36.2</quarkus.platform.version>
<quarkus-neo4j.version>6.6.1</quarkus-neo4j.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> <javaparser.version>3.26.2</javaparser.version>
<junit.version>5.11.3</junit.version> <junit.version>5.11.3</junit.version>
<compiler-plugin.version>3.13.0</compiler-plugin.version> <compiler-plugin.version>3.13.0</compiler-plugin.version>
@@ -93,18 +92,6 @@
<version>${quarkus-neo4j.version}</version> <version>${quarkus-neo4j.version}</version>
</dependency> </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> <dependency>
<groupId>org.junit.jupiter</groupId> <groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId> <artifactId>junit-jupiter</artifactId>

View File

@@ -72,10 +72,9 @@ endpoint, its parameters, its response shape, and the semantics behind them. It
before using the API in anger, and consult it whenever a response does not look the way you expected. What before using the API in anger, and consult it whenever a response does not look the way you expected. What
follows here is only the short orientation for this project's two jobs, not a replacement. follows here is only the short orientation for this project's two jobs, not a replacement.
**Priority: MCP tools → REST API → `ac` CLI → grep/Explore.** Use `mcp__agenticcode__*` first. If an MCP **Priority: REST API → `ac` CLI → grep/Explore.** Query the REST endpoints
call fails with a session/protocol error, fall back to the REST endpoint (`GET /api/projects/{project}/modules/{name}/...`) first; the `ac` CLI wraps the same API for quick
(`GET /api/projects/{project}/modules/{name}/...`) — do not let one broken MCP call push you to grep. manual checks. Fall back to reading source directly only when the question needs exact text, comments, or formatting
Fall back to reading source directly only when the question needs exact text, comments, or formatting
(which it often does here — see §3). (which it often does here — see §3).
Endpoints that matter for this work: Endpoints that matter for this work:
@@ -87,9 +86,9 @@ Endpoints that matter for this work:
| Full reachable closure | `call-tree?depth=N` | | Full reachable closure | `call-tree?depth=N` |
| Which DB tables, read or written? | `db-accesses?depth=N` | | Which DB tables, read or written? | `db-accesses?depth=N` |
| Actual SQL text | `sql-statements?depth=N` | | Actual SQL text | `sql-statements?depth=N` |
| Subroutines / methods | `module_functions`, `module_context` | | Subroutines / methods | `functions`, `context` |
| Work files | `workfile_accesses` | | Work files | `workfile-accesses` |
| Find a constant/string | `search_value`, `search_identifier` | | Find a constant/string | `search/value`, `search/identifier` |
### Known API pitfalls (verified — do not re-discover these) ### Known API pitfalls (verified — do not re-discover these)
@@ -139,7 +138,7 @@ public class MultiTableImportJob extends AbstractPurBatchJob {
To go **Java → Natural**: read `PROGRAM_IDENTIFIER` / the `// XXXXXXXX.nat` comment, then confirm the module To go **Java → Natural**: read `PROGRAM_IDENTIFIER` / the `// XXXXXXXX.nat` comment, then confirm the module
exists in `upms`. exists in `upms`.
To go **Natural → Java**: `search_value` the program name in `pur`. **An empty result means the program has To go **Natural → Java**: `search/value` the program name in `pur`. **An empty result means the program has
not been reengineered yet** — that is the normal starting state for a reengineering task, not an error. not been reengineered yet** — that is the normal starting state for a reengineering task, not an error.
--- ---
@@ -150,8 +149,8 @@ not been reengineered yet** — that is the normal starting state for a reengine
1. **Establish the pair** (§3) and state it explicitly. 1. **Establish the pair** (§3) and state it explicitly.
2. **Build the Natural ground truth via the API**: `call-tree` for the full module closure, 2. **Build the Natural ground truth via the API**: `call-tree` for the full module closure,
`db-accesses?depth=N` for the real tables (note the `via` chain), `sql-statements`, `module_functions` `db-accesses?depth=N` for the real tables (note the `via` chain), `sql-statements`, `functions`
for the subroutine inventory, `workfile_accesses` for the I/O. for the subroutine inventory, `workfile-accesses` for the I/O.
3. **Read the whole Natural source.** The API gives you structure; the semantics live in the statements, 3. **Read the whole Natural source.** The API gives you structure; the semantics live in the statements,
and — critically — in the **comments and commented-out code**. Natural programs here carry decisive and — critically — in the **comments and commented-out code**. Natural programs here carry decisive
information in comments: change history (`#01`…`#04` markers correlate to `--> #04` / `<-- #04` blocks in information in comments: change history (`#01`…`#04` markers correlate to `--> #04` / `<-- #04` blocks in

View File

@@ -31,7 +31,7 @@ Perform a **very deep analysis of all agentic API endpoints**, in two tiers:
## How to work ## How to work
1. **Use the REST API** (`GET /api/projects/pur/modules/{name}/...`), falling back to the `ac` CLI 1. **Use the REST API** (`GET /api/projects/pur/modules/{name}/...`), falling back to the `ac` CLI
for quick manual checks. Do not use MCP for this audit. for quick manual checks.
For MultiTableImportJob (and any escalated module) pull every relevant endpoint: `callees`, For MultiTableImportJob (and any escalated module) pull every relevant endpoint: `callees`,
`callers`, `db-accesses`, `functions`, `data-structures`, `dispatch-table`, `digest`, `context`, `callers`, `db-accesses`, `functions`, `data-structures`, `dispatch-table`, `digest`, `context`,
`call-tree`, `sql-statements`, `graph`. `call-tree`, `sql-statements`, `graph`.

View File

@@ -28,7 +28,7 @@ Perform a **very deep analysis of all agentic API endpoints**, in two tiers:
IMPORTANT: use the API described in `x-docs/agent-api-system-prompt.md` to analyze WGEAGB0S IMPORTANT: use the API described in `x-docs/agent-api-system-prompt.md` to analyze WGEAGB0S
1. **Use the REST API** (`GET /api/projects/upms/modules/{name}/...`), falling back to the `ac` CLI 1. **Use the REST API** (`GET /api/projects/upms/modules/{name}/...`), falling back to the `ac` CLI
for quick manual checks. Do not use MCP for this audit. for quick manual checks.
For WGEAGB0S (and any escalated module) pull every relevant endpoint: `callees`, `callers`, For WGEAGB0S (and any escalated module) pull every relevant endpoint: `callees`, `callers`,
`db-accesses`, `functions`, `data-structures`, `dispatch-table`, `digest`, `context`, `db-accesses`, `functions`, `data-structures`, `dispatch-table`, `digest`, `context`,
`call-tree`, `sql-statements`, `graph`. `call-tree`, `sql-statements`, `graph`.

View File

@@ -1,8 +1,7 @@
# AgenticCode API — Agent System Prompt # AgenticCode API — Agent System Prompt
You are an agent that analyzes source code (Software AG **Natural** and You are an agent that analyzes source code (Software AG **Natural** and
**Java**) through the AgenticCode REST API (or its MCP-tool equivalents — see **Java**) through the AgenticCode REST API. The server has already parsed the source into a unified AST
"Access via MCP"). The server has already parsed the source into a unified AST
stored as a graph in Neo4j; you query that graph read-only. Use the API as stored as a graph in Neo4j; you query that graph read-only. Use the API as
your source of truth about the code structure — do not guess at structure you your source of truth about the code structure — do not guess at structure you
can look up. (You may still need to read actual source text — see "Reading can look up. (You may still need to read actual source text — see "Reading
@@ -450,69 +449,6 @@ curl http://localhost:8787/api/projects/demo/nodes/3f9c1a2b-.../source
curl 'http://localhost:8787/api/projects/demo/modules/ZSNNA12/source?startLine=10&endLine=25' curl 'http://localhost:8787/api/projects/demo/modules/ZSNNA12/source?startLine=10&endLine=25'
``` ```
## Access via MCP
Same operations, exposed as MCP tools over HTTP/SSE at
`http://<host>:8787/mcp/sse` (server name `agenticcode`):
```
claude mcp add --transport sse agenticcode http://localhost:8787/mcp/sse
```
Each tool takes the same arguments as its REST twin (`{project}` path segment
+ query params become named arguments) and returns identical JSON. Failures
come back as `isError=true` with the same `{ error, code, details }` body.
`flow_forward`/`flow_backward`/`field_flow` return an actionable hint
pointing at `ingest_module` when the module isn't deep-ingested — out of
your read-only scope, same as the REST `409`.
**If MCP is unavailable** (not registered, session error, failed call): use
the equivalent REST endpoint instead of giving up — every tool below has one.
| REST endpoint | MCP tool |
|---------------------------------------------------------------------|---------------------------------|
| `GET /api/projects` | `list_projects` |
| `GET /api/version` | `version` |
| `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}/functions/{fn}/overrides` (or bulk, no `{fn}`) | `function_overrides` |
| `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 /search/annotation` | `search_annotation` |
| `GET /nodes/{id}` | `inspect_node` |
| `GET /nodes/{id}/source` | `node_source` |
| `GET /modules/{name}/source` | `module_source` |
| `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 /dynamic-calls/unresolved` | `list_unresolved_dynamic_calls` |
| `GET /dynamic-calls/overrides` | `list_dynamic_call_overrides` |
| `POST /dynamic-calls/overrides` | `set_dynamic_call_override` |
| `DELETE /dynamic-calls/overrides` | `reset_dynamic_call_override` |
`set_dynamic_call_override` takes `targets` as a comma-separated string
(e.g. `"A,B"`); the REST body takes a JSON array. Both are your one write
capability (item 82) — see "Resolving unresolved dynamic `CALLNAT` calls".
Project create/update/delete and the global clear have no MCP tool (REST-only,
operator scope). Ingest tools exist but are out of scope for you, same as the
REST ingest endpoints. `?fields=name` is REST-only; MCP tools always return
the full shape.
## Recommended end-to-end flow ## Recommended end-to-end flow
1. `GET /api/projects` → pick the project. 1. `GET /api/projects` → pick the project.

View File

@@ -3,14 +3,14 @@
Technische White-Box-Übersicht. AgenticCode ist ein Server, der Quellcode — Technische White-Box-Übersicht. AgenticCode ist ein Server, der Quellcode —
**Software AG Natural** und **Java** — in einen einheitlichen Graphen zerlegt, in **Software AG Natural** und **Java** — in einen einheitlichen Graphen zerlegt, in
Neo4j ablegt, semantisch anreichert und über eine agenten-optimierte Schnittstelle Neo4j ablegt, semantisch anreichert und über eine agenten-optimierte Schnittstelle
(REST + MCP + CLI) abfragbar macht. (REST + CLI) abfragbar macht.
> Grundlage: statische Analyse des Repositorys `ac` mit AgenticCode selbst > Grundlage: statische Analyse des Repositorys `ac` mit AgenticCode selbst
> (Dogfooding). Stand: Juli 2026 · Server-Version 8. Die Beschreibung folgt dem > (Dogfooding). Stand: Juli 2026 · Server-Version 8. Die Beschreibung folgt dem
> tatsächlichen Code, nicht einer Spezifikation. > tatsächlichen Code, nicht einer Spezifikation.
**Tech-Stack:** Quarkus · Java 21 (Virtual Threads) · Neo4j 5 · Mutiny (reactive) · **Tech-Stack:** Quarkus · Java 21 (Virtual Threads) · Neo4j 5 · Mutiny (reactive) ·
NullAway · JavaParser + eigener Natural-Parser · MCP über HTTP/SSE. NullAway · JavaParser + eigener Natural-Parser.
--- ---
@@ -18,7 +18,7 @@ NullAway · JavaParser + eigener Natural-Parser · MCP über HTTP/SSE.
Der Kern ist eine Pipeline: jede Quelldatei wird geparst, in ein sprachunabhängiges Der Kern ist eine Pipeline: jede Quelldatei wird geparst, in ein sprachunabhängiges
AST-Modell überführt, als Knoten-/Kanten-Graph in Neo4j persistiert, durch mehrere AST-Modell überführt, als Knoten-/Kanten-Graph in Neo4j persistiert, durch mehrere
Anreicherungs-Schritte (Enrichment) verknüpft und über REST, MCP und CLI verfügbar Anreicherungs-Schritte (Enrichment) verknüpft und über REST und CLI verfügbar
gemacht. gemacht.
``` ```
@@ -33,7 +33,7 @@ Quelldateien → Parser (Natural/Java) → Unified AST → Neo4j-Graph → Enric
| `ac-parser-natural` | Eigener Parser für Software AG Natural — es gibt keinen brauchbaren OSS-Parser. Zeilen-/Regex-basiert. Plus lexer-basierter Grob-Scanner (Tier 1). | | `ac-parser-natural` | Eigener Parser für Software AG Natural — es gibt keinen brauchbaren OSS-Parser. Zeilen-/Regex-basiert. Plus lexer-basierter Grob-Scanner (Tier 1). |
| `ac-parser-java` | Java über die `JavaParser`-Bibliothek (mit Symbol-Auflösung). Grob-Scan als Projektion des vollen Parses. | | `ac-parser-java` | Java über die `JavaParser`-Bibliothek (mit Symbol-Auflösung). Grob-Scan als Projektion des vollen Parses. |
| `ac-neo4j-store` | Persistenz & alle Cypher-Abfragen (`GraphRepository`, `CypherQueries`), Enrichment-Schritte. | | `ac-neo4j-store` | Persistenz & alle Cypher-Abfragen (`GraphRepository`, `CypherQueries`), Enrichment-Schritte. |
| `ac-code-server` | Quarkus-App: REST-Ressourcen, MCP-Tools, Dienste (Ingest, Deep-Ingest-Koordinator, Source-Snippets). | | `ac-code-server` | Quarkus-App: REST-Ressourcen, Dienste (Ingest, Deep-Ingest-Koordinator, Source-Snippets). |
| `ac-cli` | Kommandozeilen-Client (picocli) — dünner Wrapper über die REST-API. | | `ac-cli` | Kommandozeilen-Client (picocli) — dünner Wrapper über die REST-API. |
### Das einheitliche AST-Modell ### Das einheitliche AST-Modell
@@ -99,29 +99,27 @@ mitgeliefert.
## 2. Verwendung: CLI, API, Agenten ## 2. Verwendung: CLI, API, Agenten
Es gibt drei gleichwertige Zugänge auf dieselbe Logik. Für KI-Agenten ist die empfohlene Es gibt zwei gleichwertige Zugänge auf dieselbe Logik. Für KI-Agenten ist die empfohlene
Reihenfolge: **MCP zuerst**, dann REST, dann CLI, und erst als letztes klassisches Reihenfolge: **REST zuerst**, dann CLI, und erst als letztes klassisches
Durchsuchen (grep). Durchsuchen (grep).
- **MCP (für Agenten):** Werkzeuge `mcp__agenticcode__*` über HTTP/SSE. Native - **REST (programmatisch, auch für Agenten):** JAX-RS unter `/api/projects/{p}/…`. Kompaktes JSON,
Integration in Claude Code & andere Agenten.
- **REST (programmatisch):** JAX-RS unter `/api/projects/{p}/…`. Kompaktes JSON,
Pagination via `limit`/`offset`. Pagination via `limit`/`offset`.
- **CLI (manuell/Skripte):** `ac <befehl>` — dünner Wrapper über REST, für schnelle - **CLI (manuell/Skripte):** `ac <befehl>` — dünner Wrapper über REST, für schnelle
Checks am Terminal. Checks am Terminal.
### Was man abfragen kann ### Was man abfragen kann
| Zweck | MCP-Tool / REST | CLI | | Zweck | REST-Endpunkt | CLI |
|-------------------------|--------------------------------------------|------------------------| |-------------------------|-----------------------------------------------|------------------------|
| Wer ruft auf? | `callers` · `/modules/{n}/callers` | `ac callers` | | Wer ruft auf? | `/modules/{n}/callers` | `ac callers` |
| Was wird aufgerufen? | `callees` · `/modules/{n}/callees` | `ac callees` | | Was wird aufgerufen? | `/modules/{n}/callees` | `ac callees` |
| Transitiver Aufrufbaum | `call_tree` · `/call-tree` | `ac call-tree` | | Transitiver Aufrufbaum | `/modules/{n}/call-tree` | `ac call-tree` |
| Modul-Überblick | `module_context` · `/context` | `ac context` | | Modul-Überblick | `/modules/{n}/context` | `ac context` |
| DB-Zugriffe | `db_accesses` · `/db-accesses` | `ac db-accesses` | | DB-Zugriffe | `/modules/{n}/db-accesses` | `ac db-accesses` |
| Bezeichner suchen | `search_identifier` · `/search/identifier` | `ac search-identifier` | | Bezeichner suchen | `/search/identifier` | `ac search-identifier` |
| Datenfluss verfolgen | `flow_forward` / `field_flow` | `ac flow-forward` | | Datenfluss verfolgen | `/variables/{n}/flow-forward` · `/field-flow` | `ac flow-forward` |
| Re-Ingest nach Änderung | `refresh` · `POST /refresh` | `ac refresh` | | Re-Ingest nach Änderung | `POST /refresh` | `ac refresh` |
### Typischer Ablauf ### Typischer Ablauf
@@ -201,11 +199,10 @@ Legende: **[Design]** = bewusste Näherung · **[Limit]** = echte Einschränkung
- **[Limit] Node-IDs sind nicht stabil · `unresolved` nur punktuell.** Knoten-`id`s werden - **[Limit] Node-IDs sind nicht stabil · `unresolved` nur punktuell.** Knoten-`id`s werden
bei jedem Re-Ingest neu vergeben — gecachte IDs veralten. Das `unresolved`-Flag wird bei jedem Re-Ingest neu vergeben — gecachte IDs veralten. Das `unresolved`-Flag wird
explizit nur von `search_identifier` und `inspect_node` ausgegeben; in `callers`/`callees` explizit nur von `/search/identifier` und `/nodes/{id}` ausgegeben; in `callers`/`callees`
ist es nur indirekt (leeres `sourceFile`) erkennbar. ist es nur indirekt (leeres `sourceFile`) erkennbar.
- **[Umfang] Zwei Sprachen · offene Performance-Baustellen.** Unterstützt werden - **[Umfang] Zwei Sprachen · offene Performance-Baustellen.** Unterstützt werden
ausschließlich **Natural** und **Java**. Ein tiefer Ganz-Projekt-Ingest ist auf großen ausschließlich **Natural** und **Java**. Ein tiefer Ganz-Projekt-Ingest ist auf großen
Codebasen teuer (Enrichment im Minutenbereich) — genau deshalb das lazy Modell. Paralleles Codebasen teuer (Enrichment im Minutenbereich) — genau deshalb das lazy Modell. Paralleles
Parsen und gebündeltes Persistieren sind noch offen; die MCP-SSE-Session zeigt gelegentlich Parsen und gebündeltes Persistieren sind noch offen.
sporadische Reconnect-Fehler (vermutlich clientseitig).

View File

@@ -2217,3 +2217,33 @@ endpoint already exposed, so an unresolved dynamic target is machine-distinguish
now **requires** an agent to investigate and pin an unresolved dynamic call rather than report a dead end. now **requires** an agent to investigate and pin an unresolved dynamic call rather than report a dead end.
Covered by `DynamicCallOverrideIT` (6/0/0): resolve, multi-target, unknown-target rejection, inline reset Covered by `DynamicCallOverrideIT` (6/0/0): resolve, multi-target, unknown-target rejection, inline reset
restore, obsolete precedence, and override survival across a `refresh?deep=true`. restore, obsolete precedence, and override survival across a `refresh?deep=true`.
## MCP surface removed (item 26) — 2026-08-04
The MCP server was removed from the product. It existed as a second transport in front of the same
services the REST API already exposes: `McpQueryTools` (770 LoC, 38 `@Tool` methods), `McpIngestTools`
(2 tools), plus `McpSupport`/`McpLogged`/`McpLoggingInterceptor` — 973 LoC of main code and the
145-LoC `McpToolsIT`, all deleted, together with the `quarkus-mcp-server-sse`/`-test` dependencies
(root POM `dependencyManagement` + `mcp-server.version` property + `ac-code-server` POM), the
`quarkus.mcp.server.server-info.*` properties, and the repo-root `.mcp.json` client registration.
**Rationale.** Item 26 (MCP session reliability) never became fixable from this codebase: calls failed
with `"the first message from the client must be initialize: tools/call"`, at first intermittently and
by 2026-08-02 from the first call of a session onwards, while the equivalent REST endpoints answered
normally. Rather than carry a second, unreliable transport plus the CLAUDE.md rule that every REST
change be mirrored into an MCP tool, the surface was dropped.
**No capability lost.** All 40 tools were verified to have a REST twin before deletion — including the
non-obvious ones: `version` → `GET /api/version`, `ego_graph` → `GET /modules/{name}/graph`,
`file_source` → `GET /source?file=`, `project_loc` → `GET /loc`, and the three dynamic-call-override
tools → `GET`/`POST`/`DELETE /api/projects/{p}/dynamic-calls/overrides`. The MCP layer held no logic of
its own; `McpSupport` only serialized service results into the same JSON the REST resources return.
**Docs & rules updated.** `CLAUDE.md` (sync rule now REST + `ac-cli`; tool priority now REST → CLI →
grep/Explore; architecture diagram, project structure, ADR table), `README.md` (architecture diagram,
tech stack, endpoint list, "Agent usage" section replacing "MCP"), `x-docs/agent-api-system-prompt.md`
("Access via MCP" section and its REST↔tool mapping table dropped), `x-docs/mcp-api-usage-ac-implementation.md`
(tool names replaced by their endpoint paths throughout; filename kept to avoid breaking ~6 inbound
references), `x-docs/agenticcode-ueberblick.md`, `x-docs/presentation.md`, `prompts/CLAUDE.md`, both
`prompts/*-deep-api-audit.md`, and `.claude/settings.local.json`. Historical MCP mentions in this file
and in earlier `roadmap.md` entries are left intact as record.

View File

@@ -6,19 +6,18 @@ call graphs, callers/callees, DB access, dataflow, and module overviews when
working *on this repo* — it's exactly the tool this project builds. working *on this repo* — it's exactly the tool this project builds.
**For full API semantics (params, response shapes, error codes, language **For full API semantics (params, response shapes, error codes, language
applicability, MCP tool mapping, curl examples)**, see applicability, curl examples)**, see
`x-docs/agent-api-system-prompt.md` — that file is the canonical reference and `x-docs/agent-api-system-prompt.md` — that file is the canonical reference and
is not duplicated here. This file only covers what's specific to using the is not duplicated here. This file only covers what's specific to using the
API *as Claude Code, on this checkout*. API *as Claude Code, on this checkout*.
## Tool priority ## Tool priority
MCP first (`mcp__agenticcode__*`) → REST (`GET /api/projects/ac/...`) → `ac` REST first (`GET /api/projects/ac/...`) → `ac`
CLI (`ac callers`, `ac callees`, `ac call-tree`, `ac context`, `ac CLI (`ac callers`, `ac callees`, `ac call-tree`, `ac context`, `ac
db-accesses`, ...) → grep/Explore. Only fall back past REST/CLI when the db-accesses`, ...) → grep/Explore. Only fall back past REST/CLI when the
question genuinely isn't answerable by this API at all (see "Missing question genuinely isn't answerable by this API at all (see "Missing
capability" below). If an MCP call fails (session/protocol error), fall back capability" below). If the server is unreachable, try
to REST rather than abandoning the API. If the server is unreachable, try
`./manage-ac.sh deploy` before falling back further. `./manage-ac.sh deploy` before falling back further.
## Re-ingest before trusting results ## Re-ingest before trusting results
@@ -27,16 +26,15 @@ Query results reflect the last ingest, not the current working tree.
**Refresh after code changes** before trusting query results: **Refresh after code changes** before trusting query results:
`ac refresh` or `POST /api/projects/ac/refresh` (add `--deep` / `?deep=true` for a `ac refresh` or `POST /api/projects/ac/refresh` (add `--deep` / `?deep=true` for a
full field-level pass). `refresh` is the single (re-)ingest surface (item 42) — the full field-level pass). `refresh` is the single (re-)ingest surface (item 42) — the
eager `ingest_all`/`ingest_module`/`ingest-call-graph` tools were removed. eager `ingest-all`/`ingest-module`/`ingest-call-graph` endpoints were removed.
`ac refresh <name>` deep-ingests one module + its callees/data areas; add `ac refresh <name>` deep-ingests one module + its callees/data areas; add
`--neighborhood` (`POST /refresh/{name}?scope=neighborhood`) to also pull in the `--neighborhood` (`POST /refresh/{name}?scope=neighborhood`) to also pull in the
module's transitive **callers** (whole call-graph neighbourhood). `refresh` is module's transitive **callers** (whole call-graph neighbourhood).
REST + CLI only — mutations aren't exposed as MCP tools.
**Reconciliation on re-ingest (item 58).** A `refresh` now **purges stale nodes**: **Reconciliation on re-ingest (item 58).** A `refresh` now **purges stale nodes**:
for every re-parsed file it deletes the nodes the fresh parse no longer produces for every re-parsed file it deletes the nodes the fresh parse no longer produces
(renamed/removed fields, moved statements) rather than leaving them to shadow the (renamed/removed fields, moved statements) rather than leaving them to shadow the
new ones — so identifier counts and `search_identifier` results stay clean after a new ones — so identifier counts and `/search/identifier` results stay clean after a
parser change or an edited source file. Applies to every full-parse path (whole-root parser change or an edited source file. Applies to every full-parse path (whole-root
`refresh` with or without `--deep`, and per-module `refresh/{name}`); the coarse `refresh` with or without `--deep`, and per-module `refresh/{name}`); the coarse
Tier-1 scan run at project creation does not reconcile (it only ever runs on an empty Tier-1 scan run at project creation does not reconcile (it only ever runs on an empty
@@ -66,8 +64,7 @@ agents with no filesystem access.
**Stale-source check (item 41).** The `/source` endpoints compare the file on **Stale-source check (item 41).** The `/source` endpoints compare the file on
disk against the content hash (`sourceHash`) stored at ingest. If the file disk against the content hash (`sourceHash`) stored at ingest. If the file
changed since the last ingest they return `409 STALE_SOURCE` (MCP: a changed since the last ingest they return `409 STALE_SOURCE` instead of slicing current text against old line
`STALE_SOURCE` tool error) instead of slicing current text against old line
numbers — re-ingest (refresh) the project to update the graph. Line ranges you numbers — re-ingest (refresh) the project to update the graph. Line ranges you
read directly off disk are of course always current; this only guards the API's read directly off disk are of course always current; this only guards the API's
own slicing. Copycode/INCLUDE slices are raw pre-expansion file text. own slicing. Copycode/INCLUDE slices are raw pre-expansion file text.
@@ -92,8 +89,8 @@ A reference whose target isn't (yet) ingested — a `CALLNAT`/`PERFORM`/`USING`
absent from the project, or a dynamic `CALLNAT PGM-VAR` whose literal can't be recovered — is stored absent from the project, or a dynamic `CALLNAT PGM-VAR` whose literal can't be recovered — is stored
as a **deduped placeholder node** (blank `sourceFile`). Enrichment stamps each with an `unresolved` as a **deduped placeholder node** (blank `sourceFile`). Enrichment stamps each with an `unresolved`
boolean: `true` while genuinely dangling, `false` once a real definition of that name is ingested. boolean: `true` while genuinely dangling, `false` once a real definition of that name is ingested.
`search_identifier` returns it as `unresolved` on each `IdentifierMatch`, and `inspect_node` `/search/identifier` returns it as `unresolved` on each `IdentifierMatch`, and `GET /nodes/{id}` carries it in the
(`GET /nodes/{id}`) carries it in the node's properties — so an agent can tell a dangling/dynamic node's properties — so an agent can tell a dangling/dynamic
reference apart from a resolved one. In `callers`/`callees` such targets already appear as entries reference apart from a resolved one. In `callers`/`callees` such targets already appear as entries
with a blank `sourceFile`. with a blank `sourceFile`.
@@ -199,10 +196,10 @@ reproducible project totals.
Where to read them: Where to read them:
- `list_modules` (`GET /modules`) — `loc`/`sloc` on each row. - `GET /modules` — `loc`/`sloc` on each row.
- `module_context` (`GET /modules/{name}/context`) — `loc`/`sloc` on the module. - `GET /modules/{name}/context` — `loc`/`sloc` on the module.
- `inspect_node` (`GET /nodes/{id}`) — `loc`/`sloc` in the node's raw properties. - `GET /nodes/{id}` — `loc`/`sloc` in the node's raw properties.
- `project_loc` (`GET /loc`, `ac loc`) — the rollup: a per-language breakdown (`fileCount`, `loc`, - `GET /loc` (`ac loc`) — the rollup: a per-language breakdown (`fileCount`, `loc`,
`sloc`) plus a project-wide total, optionally narrowed by `?language=` / `?sourceFile=`. Each source `sloc`) plus a project-wide total, optionally narrowed by `?language=` / `?sourceFile=`. Each source
file is counted once even when it yields several nodes (Java inner classes, Natural inline groups). file is counted once even when it yields several nodes (Java inner classes, Natural inline groups).
@@ -225,7 +222,7 @@ below; it never contributes nodes/edges. So when verifying an API response again
module, always read the `generatedDir` file (e.g. `generated_src/subprogram/WGEAGB0S.nat`), not the module, always read the `generatedDir` file (e.g. `generated_src/subprogram/WGEAGB0S.nat`), not the
`user_exit` fragment. `user_exit` fragment.
`project_loc` (`GET /loc`, `ac loc`) then reports, per language row **and** in the project total: `GET /loc` (`ac loc`) then reports, per language row **and** in the project total:
- **`loc`/`sloc`** — the **total** (generated, which already includes the user exits). - **`loc`/`sloc`** — the **total** (generated, which already includes the user exits).
- **`userExitLoc`/`userExitSloc`** — the sum of the annotated user-exit twins (the hand-written part). - **`userExitLoc`/`userExitSloc`** — the sum of the annotated user-exit twins (the hand-written part).
@@ -239,7 +236,7 @@ existing project via `ac project update <name> -g generated_src -u user_exit`.
## `?depth=` means module hops (item 65) ## `?depth=` means module hops (item 65)
On `db-accesses` / `sql-statements` (and the `?module=` scope of `variables/{name}/reads|writes`), On `db-accesses` / `sql-statements` (and the `?module=` scope of `variables/{name}/reads|writes`),
`depth=N` means **N module calls away** — the same unit `ego_graph?depth=` and `call-tree` neighbours `depth=N` means **N module calls away** — the same unit `/modules/{name}/graph?depth=` and `call-tree` neighbours
use. `depth=1` = the modules this one directly `CALLNAT`s, regardless of how deeply the calling use. `depth=1` = the modules this one directly `CALLNAT`s, regardless of how deeply the calling
statement sits inside subroutines. statement sits inside subroutines.
@@ -255,7 +252,8 @@ is now the value you'd naturally expect, and inflated values just widen the resu
> **`call-tree`'s `depth` column is still raw-hop based** and mixes internal subroutines into the tree: > **`call-tree`'s `depth` column is still raw-hop based** and mixes internal subroutines into the tree:
> a direct dependency called from the main body shows `depth=1` while one called two subroutines deep > a direct dependency called from the main body shows `depth=1` while one called two subroutines deep
> shows `depth=3`. Use `ego_graph` when you need module-level distance. Tracked as an open roadmap item. > shows `depth=3`. Use the ego graph (`/modules/{name}/graph`) when you need module-level distance. Tracked as an open
> roadmap item.
## Framework-mediated DB access via `INCLUDE` macros (item 44) ## Framework-mediated DB access via `INCLUDE` macros (item 44)
@@ -326,7 +324,7 @@ Natural XML wrapper subprograms build a wire payload by mapping data-area fields
subroutine `COMPRESS`es `'<' #W-TAG '>' #W-VALUE`). The deep parser extracts that contract as subroutine `COMPRESS`es `'<' #W-TAG '>' #W-VALUE`). The deep parser extracts that contract as
`PAYLOAD_FIELD` nodes and exposes it: `PAYLOAD_FIELD` nodes and exposes it:
- `module_payload` (`GET /modules/{name}/payload`, `ac payload <module>`) → an array of - `GET /modules/{name}/payload` (`ac payload <module>`) → an array of
`{tag, field, direction, lineNo, sourceFile}` triples. `direction` is `REQUEST` for an emitted `{tag, field, direction, lineNo, sourceFile}` triples. `direction` is `REQUEST` for an emitted
(outbound) field. `field` is the unqualified payload field name (`WXMLIN.P-COD-USUARIO` → (outbound) field. `field` is the unqualified payload field name (`WXMLIN.P-COD-USUARIO` →
`P-COD-USUARIO`). **`sourceFile`** is the file `lineNo` refers to — the module's own file for `P-COD-USUARIO`). **`sourceFile`** is the file `lineNo` refers to — the module's own file for
@@ -482,7 +480,7 @@ round pulls in nothing new — so on an already-deep graph a flow query costs on
traversal plus one cheap frontier check, no re-run. traversal plus one cheap frontier check, no re-run.
**Fan-out warm (auto, on the result set).** The fan-out / traversal queries **Fan-out warm (auto, on the result set).** The fan-out / traversal queries
`callers`, `search_identifier`, and `call-tree` also auto-deep-ingest — but on `callers`, `/search/identifier`, and `call-tree` also auto-deep-ingest — but on
the **set of modules their result surfaced**, not a single named module. Each the **set of modules their result surfaced**, not a single named module. Each
runs against the graph as-is, deep-ingests the surfaced modules (blocking, runs against the graph as-is, deep-ingests the surfaced modules (blocking,
bounded by the fan-out node budget `agenticcode.deep-ingest.fanout-nodes`, bounded by the fan-out node budget `agenticcode.deep-ingest.fanout-nodes`,
@@ -503,8 +501,8 @@ stops early and the ingest response carries a `truncation` object
(`{reason: DEPTH|NODES|NODES_AND_DEPTH, maxDepth, maxNodes, hint}`) — the modules (`{reason: DEPTH|NODES|NODES_AND_DEPTH, maxDepth, maxNodes, hint}`) — the modules
actually reached are marked `FULL`, the remainder stays as it was. Raise the actually reached are marked `FULL`, the remainder stays as it was. Raise the
limits on an explicit module refresh to pull in more: limits on an explicit module refresh to pull in more:
`POST /refresh/{name}?maxDepth=&maxNodes=`, MCP `refresh(module, maxDepth, maxNodes)`, `POST /refresh/{name}?maxDepth=&maxNodes=`, or CLI `ac refresh <name> --max-depth --max-nodes`. Auto-triggered ingests
or CLI `ac refresh <name> --max-depth --max-nodes`. Auto-triggered ingests use the use the
server defaults; if a field-level query returns partial data because the target's server defaults; if a field-level query returns partial data because the target's
deep ingest truncated, re-run the explicit refresh with higher limits. (The deep ingest truncated, re-run the explicit refresh with higher limits. (The
auto-trigger does not yet accept per-query limit overrides.) auto-trigger does not yet accept per-query limit overrides.)
@@ -520,7 +518,7 @@ claim marks the module `INGESTING` and a loser waits for the winner to reach `FU
`agenticcode.deep-ingest.ingesting-ttl-seconds`, default 1800; wait bounded by `agenticcode.deep-ingest.ingesting-ttl-seconds`, default 1800; wait bounded by
`claim-wait-seconds`, default 120). A crash mid-ingest leaves the module re-triggerable `claim-wait-seconds`, default 120). A crash mid-ingest leaves the module re-triggerable
(it never reached `FULL`), and the stale `INGESTING` is reclaimed on the next call. (it never reached `FULL`), and the stale `INGESTING` is reclaimed on the next call.
`inspect_node` / `node_source` expose `ingestStatus`/`ingestStatusAt` on the module node. `GET /nodes/{id}` / `/nodes/{id}/source` expose `ingestStatus`/`ingestStatusAt` on the module node.
**Warm concurrency cap (item 37).** All auto deep-ingest/warm work (by-name, fan-out, **Warm concurrency cap (item 37).** All auto deep-ingest/warm work (by-name, fan-out,
and flow-frontier) shares a global permit pool and flow-frontier) shares a global permit pool
@@ -548,36 +546,36 @@ origins (`http://localhost:5173`, `http://localhost:4173`) — extend the
## Endpoint quick reference ## Endpoint quick reference
| Endpoint | Use for | | Endpoint | Use for |
|----------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| |----------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `GET /modules?sourceFile=&moduleKind=&extends=` | List/filter modules; map a source file to its module name(s). Each row carries `loc`/`sloc` (item 46) and `ingestStatus`/`ingestDepth` (item 50) for status badges without a per-module round trip | | `GET /modules?sourceFile=&moduleKind=&extends=` | List/filter modules; map a source file to its module name(s). Each row carries `loc`/`sloc` (item 46) and `ingestStatus`/`ingestDepth` (item 50) for status badges without a per-module round trip |
| `GET /loc?language=&sourceFile=` | Per-language LoC/SLoC rollup (fileCount/loc/sloc) + project total; each file counted once (item 46). For a generated/user_exit project also `userExitLoc`/`userExitSloc` + `generatedExclusiveLoc`/`generatedExclusiveSloc` (item 47) | | `GET /loc?language=&sourceFile=` | Per-language LoC/SLoC rollup (fileCount/loc/sloc) + project total; each file counted once (item 46). For a generated/user_exit project also `userExitLoc`/`userExitSloc` + `generatedExclusiveLoc`/`generatedExclusiveSloc` (item 47) |
| `GET /modules/{name}/digest` | Tiny triage view before deciding which modules to expand | | `GET /modules/{name}/digest` | Tiny triage view before deciding which modules to expand |
| `GET /modules/{name}/context` | One-shot overview: functions, callers, callees, DB accesses, SQL/variable summaries (`?include=` for full lists) | | `GET /modules/{name}/context` | One-shot overview: functions, callers, callees, DB accesses, SQL/variable summaries (`?include=` for full lists) |
| `GET /modules/{name}/callers` \| `/callees` | Direct callers/callees incl. `EXTENDS`/`IMPLEMENTS`/`INJECTS`/`REFERENCES`. `callers` `scope`: **`external` (default)** = modules that call this one (CALLNAT/inheritance), **rolled up to the calling MODULE**: a call made from inside a subroutine/method is attributed to its owning module (never the calling `FUNCTION` node), and repeated call sites from one caller collapse to a single row whose `sites` list every line — symmetric with how `callees` anchors its source side. `internal` = the module's own subroutines' `PERFORM` wiring (function-level). The default is external-only, module-typed only, and never lists the module as its own caller (no `MODULE→MODULE` self-loop); use `scope=internal` or `/functions/{fn}/callers` for intra-module / function-level wiring. `callees` is unchanged (default lists both external CALLNAT and internal PERFORM targets) | | `GET /modules/{name}/callers` \| `/callees` | Direct callers/callees incl. `EXTENDS`/`IMPLEMENTS`/`INJECTS`/`REFERENCES`. `callers` `scope`: **`external` (default)** = modules that call this one (CALLNAT/inheritance), **rolled up to the calling MODULE**: a call made from inside a subroutine/method is attributed to its owning module (never the calling `FUNCTION` node), and repeated call sites from one caller collapse to a single row whose `sites` list every line — symmetric with how `callees` anchors its source side. `internal` = the module's own subroutines' `PERFORM` wiring (function-level). The default is external-only, module-typed only, and never lists the module as its own caller (no `MODULE→MODULE` self-loop); use `scope=internal` or `/functions/{fn}/callers` for intra-module / function-level wiring. `callees` is unchanged (default lists both external CALLNAT and internal PERFORM targets) |
| `GET /modules/{name}/functions/{function}/callers` | **FUNCTION-level callers** (item 52): who `PERFORM`s (Natural) or calls (Java cross-class) a specific subroutine/method, with call-site `lineNos`. Finer-grained than the module-level `/callers` (which is module→module). Same `CallRefResponse` shape. MCP `function_callers`, CLI `ac function-callers <module> <function>` | | `GET /modules/{name}/functions/{function}/callers` | **FUNCTION-level callers** (item 52): who `PERFORM`s (Natural) or calls (Java cross-class) a specific subroutine/method, with call-site `lineNos`. Finer-grained than the module-level `/callers` (which is module→module). Same `CallRefResponse` shape. CLI `ac function-callers <module> <function>` |
| `GET /modules/{name}/call-tree?depth=` | Transitive call graph to scope a feature | | `GET /modules/{name}/call-tree?depth=` | Transitive call graph to scope a feature |
| `GET /dynamic-calls/unresolved` \| `/overrides` · `POST`/`DELETE /overrides` | **Manual dynamic-`CALLNAT` overrides (item 82).** `unresolved` lists open `CALLNAT <var>` sites `{module, originFile, lineNo, variable}`; `POST /overrides {originFile, lineNo, targets[], variable?, note?}` pins a site to real module(s) (applied at once, persisted across refreshes, `400 UNKNOWN_TARGET` for a non-module); `DELETE /overrides?originFile=&lineNo=` resets one site (omit both = all) and restores the placeholder inline; `GET /overrides` lists them with an `obsolete` flag. MCP `list_unresolved_dynamic_calls`/`list_dynamic_call_overrides`/`set_dynamic_call_override`/`reset_dynamic_call_override`, CLI `ac dynamic-calls unresolved\|overrides\|set\|reset` | | `GET /dynamic-calls/unresolved` \| `/overrides` · `POST`/`DELETE /overrides` | **Manual dynamic-`CALLNAT` overrides (item 82).** `unresolved` lists open `CALLNAT <var>` sites `{module, originFile, lineNo, variable}`; `POST /overrides {originFile, lineNo, targets[], variable?, note?}` pins a site to real module(s) (applied at once, persisted across refreshes, `400 UNKNOWN_TARGET` for a non-module); `DELETE /overrides?originFile=&lineNo=` resets one site (omit both = all) and restores the placeholder inline; `GET /overrides` lists them with an `obsolete` flag. CLI `ac dynamic-calls unresolved\|overrides\|set\|reset` |
| `GET /modules/{name}/graph?direction=&depth=&limit=` | Ego graph (item 49): bounded module-level call neighbourhood as **nodes + edges** (unlike call-tree). `direction` = `out`/`in`/`both`; `limit` caps nodes (BFS order) and sets `truncated`; unresolved targets carry `unresolved=true` + empty `sourceFile`. MCP `ego_graph`, CLI `ac ego-graph` | | `GET /modules/{name}/graph?direction=&depth=&limit=` | Ego graph (item 49): bounded module-level call neighbourhood as **nodes + edges** (unlike call-tree). `direction` = `out`/`in`/`both`; `limit` caps nodes (BFS order) and sets `truncated`; unresolved targets carry `unresolved=true` + empty `sourceFile`. CLI `ac ego-graph` |
| `GET /modules/{name}/db-accesses` \| `/sql-statements` | DB tables + mode, raw statement text (pass `?depth=` for Natural). **`db-accesses`/`workfile-accesses` return every row when no `limit` is given (item 103)** — they used to default to 50, and since the response is a bare array with no total and no `truncated` flag the cut was invisible: `WGEAGB0S?depth=10` returned 50 of 64 rows and hid 7 tables outright. An explicit `limit` is still honoured exactly. `db-accesses` items carry **`sites: [{lineNo, sourceFile, viaCopycode, includedAt}]`** (+ kept `lineNos`); `sql-statements` items carry **`sourceFile`** + **`viaCopycode`** — so a copycode-sourced access (e.g. `SELECT … FROM SYSIBM-SYSDUMMY1` in `USIX043C.cpy`) reports the `.cpy` line, not a bare number that reads as a host-file line | | `GET /modules/{name}/db-accesses` \| `/sql-statements` | DB tables + mode, raw statement text (pass `?depth=` for Natural). **`db-accesses`/`workfile-accesses` return every row when no `limit` is given (item 103)** — they used to default to 50, and since the response is a bare array with no total and no `truncated` flag the cut was invisible: `WGEAGB0S?depth=10` returned 50 of 64 rows and hid 7 tables outright. An explicit `limit` is still honoured exactly. `db-accesses` items carry **`sites: [{lineNo, sourceFile, viaCopycode, includedAt}]`** (+ kept `lineNos`); `sql-statements` items carry **`sourceFile`** + **`viaCopycode`** — so a copycode-sourced access (e.g. `SELECT … FROM SYSIBM-SYSDUMMY1` in `USIX043C.cpy`) reports the `.cpy` line, not a bare number that reads as a host-file line |
| `GET /modules/{name}/workfile-accesses` | Natural **work files** (sequential/flat-file I/O — `READ`/`WRITE WORK FILE n`), the work-file analogue of `db-accesses` (item 84): `[{workFile, physicalName, mode: READS\|WRITES, recordBuffers, lineNos, sites}]`, aggregated per work-file number + mode. `sites: [{lineNo, sourceFile, viaCopycode, includedAt}]` gives each access its file context (copycode-aware), like `db-accesses`. `physicalName` comes from a `DEFINE WORK FILE n '<name>'`, else `null`. **Kept separate from `db-accesses`** — a work file is not an ADABAS/SQL table (fixes a former bug where `READ WORK FILE` created a phantom `DB_TABLE 'WORK'`). MCP `workfile_accesses`, CLI `ac workfile-accesses <module>` | | `GET /modules/{name}/workfile-accesses` | Natural **work files** (sequential/flat-file I/O — `READ`/`WRITE WORK FILE n`), the work-file analogue of `db-accesses` (item 84): `[{workFile, physicalName, mode: READS\|WRITES, recordBuffers, lineNos, sites}]`, aggregated per work-file number + mode. `sites: [{lineNo, sourceFile, viaCopycode, includedAt}]` gives each access its file context (copycode-aware), like `db-accesses`. `physicalName` comes from a `DEFINE WORK FILE n '<name>'`, else `null`. **Kept separate from `db-accesses`** — a work file is not an ADABAS/SQL table (fixes a former bug where `READ WORK FILE` created a phantom `DB_TABLE 'WORK'`). CLI `ac workfile-accesses <module>` |
| `GET /modules/{name}/data-structures` | Which copybooks/inline groups a module uses. A `USING <member>` binds by **member (file) name**, never by a level-1 record inside the file (item 100) — before that, `WGEAGB0S USING W-WIF-A2` reported `old/W-WIF-A7.pda` (whose level-1 record is a copy-pasted `1W-WIF-A2`), and a data area with several level-1 records and none named after the member (`VLAYERLA.lda`, `USIX020L.lda`) resolved to nothing at all (`sourceFile: null`, `area: UNKNOWN`, `fieldCount: 0`) although the file was ingested. One row per resolved definition, `(name, sourceFile)` (item 102) — never one row blending an arbitrary file with another definition's `fieldCount` | | `GET /modules/{name}/data-structures` | Which copybooks/inline groups a module uses. A `USING <member>` binds by **member (file) name**, never by a level-1 record inside the file (item 100) — before that, `WGEAGB0S USING W-WIF-A2` reported `old/W-WIF-A7.pda` (whose level-1 record is a copy-pasted `1W-WIF-A2`), and a data area with several level-1 records and none named after the member (`VLAYERLA.lda`, `USIX020L.lda`) resolved to nothing at all (`sourceFile: null`, `area: UNKNOWN`, `fieldCount: 0`) although the file was ingested. One row per resolved definition, `(name, sourceFile)` (item 102) — never one row blending an arbitrary file with another definition's `fieldCount` |
| `GET /modules/{name}/payload` | Natural XML wire-payload contract: `{tag, field, direction, source, lineNo, sourceFile}` — static `ADD-XML-LINE` idiom (`source=IDIOM`, item 45) or derived from the wrapper's interface PDA (`source=PDA`, item 46b). `sourceFile` is the file `lineNo` refers to (module for IDIOM, PDA for PDA) | | `GET /modules/{name}/payload` | Natural XML wire-payload contract: `{tag, field, direction, source, lineNo, sourceFile}` — static `ADD-XML-LINE` idiom (`source=IDIOM`, item 45) or derived from the wrapper's interface PDA (`source=PDA`, item 46b). `sourceFile` is the file `lineNo` refers to (module for IDIOM, PDA for PDA) |
| `GET /modules/{name}/dispatch-table` | Natural `DECIDE ON VALUE OF` routing table | | `GET /modules/{name}/dispatch-table` | Natural `DECIDE ON VALUE OF` routing table |
| `GET /modules/{name}/functions?kind=` \| `/functions/{fn}/overrides` \| `/functions/overrides` | Method list, modifier filter (Java), subclass overrides (single/bulk). Each item carries **`sourceFile`** + **`viaCopycode`** (item 84): a Natural subroutine pulled in via `INCLUDE` reports the **copycode** file and `viaCopycode:true`, so its `startLine`/`endLine` are read as offsets into that copycode — **not** into the including module's own file (which is shorter). `viaCopycode:false` = declared inline. Always `false` for Java | | `GET /modules/{name}/functions?kind=` \| `/functions/{fn}/overrides` \| `/functions/overrides` | Method list, modifier filter (Java), subclass overrides (single/bulk). Each item carries **`sourceFile`** + **`viaCopycode`** (item 84): a Natural subroutine pulled in via `INCLUDE` reports the **copycode** file and `viaCopycode:true`, so its `startLine`/`endLine` are read as offsets into that copycode — **not** into the including module's own file (which is shorter). `viaCopycode:false` = declared inline. Always `false` for Java |
| `GET /data-structures/{name}/fields` \| `/db-tables/{name}/columns` \| `/modules/{name}/columns` | Field/column schemas for DTO/entity generation. Every field carries **`sourceFile`** (item 101). When a structure name resolves to several definitions (42 level-1 names recur across `upms` data areas), the **member root** — the definition whose file basename equals the name, i.e. what a `USING <member>` binds to — wins; **`?sourceFile=`** pins a specific one. Before item 101 the definitions were silently unioned: `W-WIF-A2` returned 15 fields, the merge of `W-WIF-A2.pda` (5) and `W-WIF-A7.pda` (10), a layout that exists nowhere | | `GET /data-structures/{name}/fields` \| `/db-tables/{name}/columns` \| `/modules/{name}/columns` | Field/column schemas for DTO/entity generation. Every field carries **`sourceFile`** (item 101). When a structure name resolves to several definitions (42 level-1 names recur across `upms` data areas), the **member root** — the definition whose file basename equals the name, i.e. what a `USING <member>` binds to — wins; **`?sourceFile=`** pins a specific one. Before item 101 the definitions were silently unioned: `W-WIF-A2` returned 15 fields, the merge of `W-WIF-A2.pda` (5) and `W-WIF-A7.pda` (10), a layout that exists nowhere |
| `GET /variables/{name}/reads` \| `/writes` \| `/flow-forward` \| `/flow-backward` \| `/field-flow` | Impact analysis and dataflow tracing | | `GET /variables/{name}/reads` \| `/writes` \| `/flow-forward` \| `/flow-backward` \| `/field-flow` | Impact analysis and dataflow tracing |
| `GET /search/identifier` \| `/search/value` \| `/search/annotation` | Cross-project lookup by name / literal value / annotation. `search/identifier` matches the **exact** declared name but is **sigil-insensitive**: a leading Natural sigil (`#` user, `&` AIV, `+` GDA) is ignored on both sides, so `name=K-OUT-MAX` finds the declared `#K-OUT-MAX` (and vice-versa). Optional **scope** filters `sourceFile=<relpath>` and `module=<name>` (item 53) narrow the match to one file / one module — use them to pinpoint a module-local declaration when a name recurs across dozens of modules (the result is otherwise paginated and the local one may fall off the page). To keep the **full cross-project list** yet still guarantee a given module's own declaration is on the first page, pass `priorityModule=<name>` instead of `module=`: it does not filter, but pins that module's matches to the front (ahead of the otherwise `sourceFile`-ordered rest) so they survive the `limit`. This is what the web UI's click-to-identify sends for the open module. MCP `search_identifier` / CLI `ac search-identifier --module --priority-module --source-file --type` accept the same filters. **Latency (item 105):** a lookup whose hits lie in a Natural data area used to take 60-75 s — every fan-out query deep-ingested the surfaced `.lda`/`.pda`, which can never reach `FULL` (a data area yields no `MODULE` node), so it was re-warmed on every call and each warm dragged a whole-project finalize behind it. Data areas are now excluded from the fan-out warm; they have no deep tier to gain | | `GET /search/identifier` \| `/search/value` \| `/search/annotation` | Cross-project lookup by name / literal value / annotation. `search/identifier` matches the **exact** declared name but is **sigil-insensitive**: a leading Natural sigil (`#` user, `&` AIV, `+` GDA) is ignored on both sides, so `name=K-OUT-MAX` finds the declared `#K-OUT-MAX` (and vice-versa). Optional **scope** filters `sourceFile=<relpath>` and `module=<name>` (item 53) narrow the match to one file / one module — use them to pinpoint a module-local declaration when a name recurs across dozens of modules (the result is otherwise paginated and the local one may fall off the page). To keep the **full cross-project list** yet still guarantee a given module's own declaration is on the first page, pass `priorityModule=<name>` instead of `module=`: it does not filter, but pins that module's matches to the front (ahead of the otherwise `sourceFile`-ordered rest) so they survive the `limit`. This is what the web UI's click-to-identify sends for the open module. CLI `ac search-identifier --module --priority-module --source-file --type` accept the same filters. **Latency (item 105):** a lookup whose hits lie in a Natural data area used to take 60-75 s — every fan-out query deep-ingested the surfaced `.lda`/`.pda`, which can never reach `FULL` (a data area yields no `MODULE` node), so it was re-warmed on every call and each warm dragged a whole-project finalize behind it. Data areas are now excluded from the fan-out warm; they have no deep tier to gain |
| `GET /search/source?regex=&limit=&ignoreCase=` (MCP `search_source`, `ac search-source`) | Regex **grep over module source text** (item 54): `{module, sourceFile, lineNo, line}` hits + `truncated`. Case-insensitive by default. Complements `search_identifier` (declared names) — use for code patterns (statements, table names, literals) | | `GET /search/source?regex=&limit=&ignoreCase=` (`ac search-source`) | Regex **grep over module source text** (item 54): `{module, sourceFile, lineNo, line}` hits + `truncated`. Case-insensitive by default. Complements `/search/identifier` (declared names) — use for code patterns (statements, table names, literals) |
| `GET /nodes/{id}` | Every property of one node (when a curated DTO is missing something) | | `GET /nodes/{id}` | Every property of one node (when a curated DTO is missing something) |
| `GET /nodes/{id}/source` \| `/modules/{name}/source` \| `/source?file=` | Source text — **only when you have no other access to the source** (you always do in this repo, see "Reading source in this repo" above). `module_source` returns the **whole file** when the line range is omitted (M1), or a `[startLine,endLine]` slice when both are given. `/source?file=<relpath>` (MCP `file_source`, CLI `ac file-source`) serves a file by **relative path** rather than module name — for files that aren't standalone modules, e.g. a Natural data area (PDA/LDA) USING'd by a module, whose field line numbers refer to that file. Same whole-file/range + stale-source semantics; the client-supplied path is rejected (`400 INVALID_SOURCE_FILE`) if it escapes the project root | | `GET /nodes/{id}/source` \| `/modules/{name}/source` \| `/source?file=` | Source text — **only when you have no other access to the source** (you always do in this repo, see "Reading source in this repo" above). `/modules/{name}/source` returns the **whole file** when the line range is omitted (M1), or a `[startLine,endLine]` slice when both are given. `/source?file=<relpath>` (CLI `ac file-source`) serves a file by **relative path** rather than module name — for files that aren't standalone modules, e.g. a Natural data area (PDA/LDA) USING'd by a module, whose field line numbers refer to that file. Same whole-file/range + stale-source semantics; the client-supplied path is rejected (`400 INVALID_SOURCE_FILE`) if it escapes the project root |
Full endpoint list, request params, and response field details: Full endpoint list, request params, and response field details:
`x-docs/agent-api-system-prompt.md`. `x-docs/agent-api-system-prompt.md`.
## Missing capability? ## Missing capability?
If the API/MCP/CLI genuinely cannot answer a question (not just If the API/CLI genuinely cannot answer a question (not just
unreachable — the capability doesn't exist), finish the task via unreachable — the capability doesn't exist), finish the task via
grep/Explore as a fallback, then use `AskUserQuestion` to flag the gap and grep/Explore as a fallback, then use `AskUserQuestion` to flag the gap and
ask whether it should become a roadmap item in `x-docs/roadmap.md`. Don't ask whether it should become a roadmap item in `x-docs/roadmap.md`. Don't

View File

@@ -144,4 +144,3 @@ mapping, business logic, and a complete picture of what else is affected.
| Java parser | JavaParser library | | Java parser | JavaParser library |
| API | JAX-RS / RESTEasy Reactive | | API | JAX-RS / RESTEasy Reactive |
| Tests | JUnit 5 + RestAssured + Testcontainers | | Tests | JUnit 5 + RestAssured + Testcontainers |
| MCP integration | In progress |

View File

@@ -957,25 +957,12 @@ lands in both ingest tiers at once.)*
`DynamicCallnatFoldIT` (fold resolves `YABALKEY`+`GN0@6/3` → `YABALGN0`; manual override wins); `DynamicCallnatFoldIT` (fold resolves `YABALKEY`+`GN0@6/3` → `YABALGN0`; manual override wins);
full dynamic-callnat regression 89/89 green. Docs in `mcp-api-usage-ac-implementation.md`. full dynamic-callnat regression 89/89 green. Docs in `mcp-api-usage-ac-implementation.md`.
- [ ] **26. MCP session reliability (POSTPONED 2026-07-13)** (investigated 2026-07-07, not fixed — - [x] **26. MCP session reliability — RESOLVED BY REMOVAL (2026-08-04)** (investigated 2026-07-07,
see below) — `mcp__agenticcode__module_functions` (and potentially other MCP reproduced 2026-08-02, never fixed). `mcp__agenticcode__*` calls intermittently — and in the
tools) intermittently failed with `"the first message from the client must be 2026-08-02 session, from the very first call — failed with `"the first message from the client must
initialize: tools/call"` after a sequence of prior successful MCP calls in the be initialize: tools/call"`, forcing every playbook step to be re-expressed as `curl`. The evidence
same session, forcing a fallback to the equivalent REST endpoint. Findings: pointed at the MCP client's reconnect handling rather than a server-side bug this codebase's config
no session/idle-timeout is configured server-side could fix, and the REST endpoints answered normally throughout. **Decision 2026-08-04: the MCP
(`quarkus.http.idle-timeout` unset), and `application.properties` only sets server surface was removed entirely** rather than debugged — see "MCP surface removed" in
`quarkus.mcp.server.server-info.*`. The error shape (client sent `tools/call` `x-docs/features.md`. REST + `ac` CLI are now the only access paths; all 40 former tools had a REST
without a fresh `initialize`) points at the **MCP client's** reconnect twin, so no capability was lost.
handling on a new SSE stream, not a server-side bug this codebase's config
can fix. Left open pending either a `quarkus-mcp-server-sse` version bump
with related fixes, or evidence this is in fact server-triggered.
**2026-08-02 — reproduced, and worse than "intermittent" in this session.** Every MCP call failed with
the same message from the *first* call onwards (`mcp__agenticcode__module_dispatch_table`, then
`mcp__agenticcode__list_projects`), so the whole `upms` webservice audit ran over REST via `curl`
instead. Two observations that may narrow it: (a) it was not a degradation after a run of successful
calls — the very first tool call in the session failed, which argues against an idle/reconnect timeout
and for the session never being established; (b) the equivalent REST endpoints answered normally
throughout, so the server and the graph were healthy. Practical impact for agent work: the documented
MCP surface is unusable in these sessions and every playbook step has to be re-expressed as `curl`,
which is why the REST examples in `agent-api-system-prompt.md` earn their keep.