Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,61 @@ for that specific tag for the per-commit details.
`McpToolsTest#readFileShouldHandleMissingFile` updated for new
envelope contract. Full suite: 3680 tests / 0 failures / 0 errors.

- **Production-readiness PR 5 of 5 — config validation, integration coverage,
docs refresh.** Final PR of the production-readiness series. Closes the
remaining audit findings around silent oversized-input clamping, missing
end-to-end coverage of the serving filter chain, and stale tech-stack
pins in `CLAUDE.md`.
- **MCP request-bound clamping in `McpTools`.** `queryNodes` /
`queryEdges` `limit` parameters are now `Math.min(requested,
mcp.limits.max_results)` so a caller asking for `LIMIT 1_000_000` no
longer trips the JVM into a multi-GB allocation before the
`run_cypher` row cap kicks in. `getEgoGraph` radius is clamped to
`mcp.limits.max_depth` for the same reason — a `radius=999` ego
walk on a hub node is a Cartesian explosion. `searchGraph` limit
follows the same rule. Per-call defense-in-depth on top of the
transaction-timeout cap from PR 2.
- **`ConfigValidator` hard ceilings + blank-string checks.** Added
explicit validations for fields previously only typed as
`Integer`/`Long` with no range:
- `mcp.limits.max_payload_bytes` — must be `> 0` (was silently
`null` → no payload cap → infinite-row run_cypher could OOM).
- `mcp.limits.rate_per_minute` — must be `> 0`.
- `mcp.limits.max_depth` — must be `1..100`. The 100 ceiling is a
DoS sentinel: variable-length Cypher with depth >100 is
pathological in practice (a graph with 100M nodes and fan-out 5
reaches every node by depth 12), so anything higher is either a
misconfig or a reconnaissance probe. Catch at config-load, not
at query time.
- `mcp.auth.token_env` / `mcp.auth.token` — when mode=bearer,
blank-string values fail validation rather than being silently
coerced to null and then fail-fasting at startup with a
mysterious "no token resolved" message.
- **`ServingChainIntegrationTest` (new — 9 cases).** Fills the gap
where each filter (`RequestIdFilter`, `SecurityHeadersFilter`,
`RateLimitFilter`, `BearerAuthFilter`) had unit-test coverage in
isolation but no test exercised the full chain together. Asserts
the cross-filter contract: 401 envelope shape with `request_id`
echoed in the `X-Request-Id` response header; 429 envelope with
`Retry-After` and `X-RateLimit-Remaining: 0`; security headers
present on every response (success, 401, 429); inbound
`X-Request-Id` propagated end-to-end when valid; control-char
inbound rejected and replaced with a generated UUID; rate-limit
bucket isolation per token (one client exhausting their bucket
does not affect another); health endpoint bypasses auth (kubelet
probes carry no token). Manually chains the four filters via
lambda `FilterChain` instances rather than spinning up a full
`@SpringBootTest` so the run is sub-second and doesn't need
Neo4j. Lives in `io.github.randomcodespace.iq.config.security`
package to access package-private `TokenResolver.resolve()`.
- **`CLAUDE.md` tech-stack pin refresh.** Stale version pins
updated to current: Spring Boot 4.0.5 → 4.0.6, Spring AI 2.0.0-M3
→ 2.0.0-M4, Neo4j Embedded 2026.02.3 → 2026.04.0; added
Bucket4j 8.18.0, logstash-logback-encoder 9.0,
micrometer-registry-prometheus to the dependency list.
- **Tests:** new `ServingChainIntegrationTest` (9 cases). Full
suite: 3689 tests / 0 failures / 0 errors / 32 skipped.

## [0.1.0] - 2026-03-28

First general-availability cut. See the
Expand Down
14 changes: 11 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,18 @@

## Tech Stack

> **Source of truth:** `pom.xml` and `src/main/frontend/package.json`. Update
> these pins together — when `pom.xml` bumps, this list moves with it as part
> of the same PR. Pre-PR-5 the list had drifted (Spring Boot 4.0.5,
> Neo4j 2026.02.3, Spring AI 2.0.0-M3); PR 5 brought it back in sync.

- Java 25 (virtual threads, pattern matching, records, sealed classes)
- Spring Boot 4.0.5
- Neo4j Embedded 2026.02.3 (Community Edition, no external server)
- Spring AI 2.0.0-M3 (MCP server, `@McpTool` annotations, streamable HTTP)
- Spring Boot 4.0.6 (parent POM `<version>`)
- Neo4j Embedded 2026.04.0 (Community Edition, no external server)
- Spring AI 2.0.0-M4 (MCP server, `@McpTool` annotations, streamable HTTP)
- Bucket4j 8.18.0 (`bucket4j_jdk17-core`, in-process token-bucket rate limiter)
- logstash-logback-encoder 9.0 (JSON-structured logging in `serving` profile)
- micrometer-registry-prometheus (`/actuator/prometheus`, version managed by Boot BOM)
- JavaParser 3.28.0 (Java AST analysis)
- ANTLR 4.13.2 (TypeScript/JavaScript, Python, Go, C#, Rust, C++ grammars)
- Picocli 4.7.7 (CLI framework, integrated with Spring Boot)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,45 @@ public List<ConfigError> validate(CodeIqUnifiedConfig c) {
Integer maxRes = c.mcp().limits().maxResults();
if (maxRes != null && maxRes <= 0)
errs.add(new ConfigError("mcp.limits.max_results", "must be > 0", "validator"));
Long maxPayload = c.mcp().limits().maxPayloadBytes();
if (maxPayload != null && maxPayload <= 0)
errs.add(new ConfigError("mcp.limits.max_payload_bytes", "must be > 0", "validator"));
Integer ratePerMin = c.mcp().limits().ratePerMinute();
if (ratePerMin != null && ratePerMin <= 0)
errs.add(new ConfigError("mcp.limits.rate_per_minute", "must be > 0", "validator"));
Integer maxDepth = c.mcp().limits().maxDepth();
if (maxDepth != null) {
if (maxDepth <= 0)
errs.add(new ConfigError("mcp.limits.max_depth", "must be > 0", "validator"));
// Hard ceiling on max_depth — variable-length Cypher with depth >100
// is almost always either a misconfig or a reconnaissance probe.
// A graph with 100M nodes and a fan-out of 5 reaches every node by
// depth 12 anyway; depth >100 is pathological in practice.
if (maxDepth > 100)
errs.add(new ConfigError("mcp.limits.max_depth",
"must be <= 100 (variable-length Cypher above this depth is "
+ "pathological); got " + maxDepth, "validator"));
}

// mcp.auth.* — blank-string checks for required fields
// When mcp.auth.mode=bearer, either token_env (env var name) or token
// (literal config value) must resolve to a non-blank string. The
// TokenResolver also fail-fasts at startup, but catching this in
// `codeiq config validate` lets operators see the issue before
// launching the server.
if ("bearer".equalsIgnoreCase(c.mcp().auth().mode())) {
String tokenEnvName = c.mcp().auth().tokenEnv();
String tokenLiteral = c.mcp().auth().token();
// Blank means "set but empty" — silently coerced to null at
// config-read but TokenResolver would still fail. Catch here.
if (tokenEnvName != null && tokenEnvName.isBlank())
errs.add(new ConfigError("mcp.auth.token_env",
"must be non-blank when set (use unset for default CODEIQ_MCP_TOKEN)",
"validator"));
if (tokenLiteral != null && tokenLiteral.isBlank())
errs.add(new ConfigError("mcp.auth.token",
"must be non-blank when set", "validator"));
}

// observability.log_format / log_level
if (c.observability().logFormat() != null && !LOG_FORMATS.contains(c.observability().logFormat()))
Expand Down
25 changes: 19 additions & 6 deletions src/main/java/io/github/randomcodespace/iq/mcp/McpTools.java
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,13 @@ public String queryNodes(
@McpToolParam(description = "Node kind to filter by: endpoint, entity, class, method, guard, service, module, topic, queue, config_file, database_connection, component, interface, enum, etc.", required = false) String kind,
@McpToolParam(description = "Maximum number of results to return (default: 50)", required = false) Integer limit) {
try {
return toJson(queryService.listNodes(kind, limit != null ? limit : 50, 0));
// Clamp caller-supplied limit to mcp.limits.max_results (default 500).
// Pre-PR-5 a caller could ask for `limit: 1_000_000` and Neo4j
// would happily stream a million rows over the MCP transport,
// saturating the JSON encoder and the network. Part of the
// rate-limit / abuse-protection story (RAN-46 #2/#3).
int safeLimit = Math.min(limit != null ? limit : 50, maxResults);
return toJson(queryService.listNodes(kind, safeLimit, 0));
} catch (Exception e) {
return errorEnvelope("INTERNAL_ERROR", e);
}
Expand All @@ -181,7 +187,8 @@ public String queryEdges(
@McpToolParam(description = "Edge kind to filter by: calls, imports, depends_on, queries, produces, consumes, protects, extends, implements, contains, connects_to, maps_to, etc.", required = false) String kind,
@McpToolParam(description = "Maximum number of results to return (default: 50)", required = false) Integer limit) {
try {
return toJson(queryService.listEdges(kind, limit != null ? limit : 50, 0));
int safeLimit = Math.min(limit != null ? limit : 50, maxResults);
return toJson(queryService.listEdges(kind, safeLimit, 0));
} catch (Exception e) {
return errorEnvelope("INTERNAL_ERROR", e);
}
Expand All @@ -201,9 +208,14 @@ public String getNodeNeighbors(
@McpTool(name = "get_ego_graph", description = "Get the full subgraph within N hops of a center node — all reachable nodes and edges. Use for exploring the neighborhood of a component, understanding local architecture, or visualizing a module's context. Returns nodes and edges as a graph structure.")
public String getEgoGraph(
@McpToolParam(description = "Center node ID") String center,
@McpToolParam(description = "Number of hops from center node (default: 2, max: 10)", required = false) Integer radius) {
@McpToolParam(description = "Number of hops from center node (default: 2, max: configured mcp.limits.max_depth)", required = false) Integer radius) {
try {
return toJson(queryService.egoGraph(center, radius != null ? radius : 2));
// Clamp radius to mcp.limits.max_depth (default 10). Pre-PR-5
// the description claimed "max: 10" but no code enforced it —
// a caller could ask for radius=999 and Neo4j would attempt a
// [*1..999] variable-length match.
int safeRadius = Math.min(radius != null ? radius : 2, maxDepth);
return toJson(queryService.egoGraph(center, safeRadius));
} catch (Exception e) {
return errorEnvelope("INTERNAL_ERROR", e);
}
Expand Down Expand Up @@ -429,9 +441,10 @@ public String findRelatedEndpoints(
@McpTool(name = "search_graph", description = "Full-text search across all node labels, IDs, file paths, and properties. Use as the starting point when the user mentions a name but you don't have the exact node ID. Returns matching nodes ranked by relevance.")
public String searchGraph(
@McpToolParam(description = "Search query") String query,
@McpToolParam(description = "Maximum results (default: 20)", required = false) Integer limit) {
@McpToolParam(description = "Maximum results (default: 20, hard cap: configured mcp.limits.max_results)", required = false) Integer limit) {
try {
return toJson(queryService.searchGraph(query, limit != null ? limit : 20));
int safeLimit = Math.min(limit != null ? limit : 20, maxResults);
return toJson(queryService.searchGraph(query, safeLimit));
} catch (Exception e) {
return errorEnvelope("INTERNAL_ERROR", e);
}
Expand Down
Loading
Loading