Skip to content

Commit 4ec4134

Browse files
aksOpsclaude
andcommitted
refactor: make MCP and API strictly read-only — no data manipulation
Removed analyze_codebase from MCP tools and POST /api/analyze from REST API. The serving layer (MCP + API + UI) is read-only — it queries the pre-built graph, never modifies it. Architecture: Local machine: code-iq analyze/index → H2 cache + bundle Remote server: code-iq serve (auto-enrich from H2 → Neo4j) → read-only API/MCP The remote server may not have source code access (bundle deployment model), so triggering analysis from MCP/API was broken by design. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 549f135 commit 4ec4134

5 files changed

Lines changed: 19 additions & 143 deletions

File tree

src/main/java/io/github/randomcodespace/iq/api/GraphController.java

Lines changed: 4 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
package io.github.randomcodespace.iq.api;
22

3-
import io.github.randomcodespace.iq.analyzer.AnalysisResult;
4-
import io.github.randomcodespace.iq.analyzer.Analyzer;
53
import io.github.randomcodespace.iq.config.CodeIqConfig;
64
import io.github.randomcodespace.iq.query.QueryService;
75
import org.springframework.http.HttpStatus;
@@ -10,7 +8,6 @@
108
import org.springframework.context.annotation.Profile;
119
import org.springframework.web.bind.annotation.GetMapping;
1210
import org.springframework.web.bind.annotation.PathVariable;
13-
import org.springframework.web.bind.annotation.PostMapping;
1411
import org.springframework.web.bind.annotation.RequestMapping;
1512
import org.springframework.web.bind.annotation.RequestParam;
1613
import org.springframework.web.bind.annotation.RestController;
@@ -20,13 +17,8 @@
2017
import java.nio.charset.StandardCharsets;
2118
import java.nio.file.Files;
2219
import java.nio.file.Path;
23-
import java.util.LinkedHashMap;
2420
import java.util.List;
2521
import java.util.Map;
26-
import java.util.concurrent.atomic.AtomicBoolean;
27-
28-
import io.github.randomcodespace.iq.graph.GraphStore;
29-
import org.springframework.cache.CacheManager;
3022

3123
/**
3224
* REST API controller matching the Python OSSCodeIQ API paths.
@@ -37,22 +29,12 @@
3729
public class GraphController {
3830

3931
private final QueryService queryService;
40-
private final Analyzer analyzer;
4132
private final CodeIqConfig config;
42-
private final CacheManager cacheManager;
43-
private final GraphStore graphStore;
44-
private final AtomicBoolean analysisRunning = new AtomicBoolean(false);
4533

4634
public GraphController(@org.springframework.beans.factory.annotation.Autowired(required = false) QueryService queryService,
47-
Analyzer analyzer,
48-
CodeIqConfig config,
49-
@org.springframework.beans.factory.annotation.Autowired(required = false) CacheManager cacheManager,
50-
@org.springframework.beans.factory.annotation.Autowired(required = false) GraphStore graphStore) {
35+
CodeIqConfig config) {
5136
this.queryService = queryService;
52-
this.analyzer = analyzer;
5337
this.config = config;
54-
this.cacheManager = cacheManager;
55-
this.graphStore = graphStore;
5638
}
5739

5840
@GetMapping("/stats")
@@ -268,39 +250,7 @@ public ResponseEntity<String> readFile(
268250
}
269251
}
270252

271-
@PostMapping("/analyze")
272-
public ResponseEntity<?> triggerAnalysis(
273-
@RequestParam(defaultValue = "false") boolean incremental) {
274-
if (!analysisRunning.compareAndSet(false, true)) {
275-
return ResponseEntity.status(HttpStatus.CONFLICT)
276-
.body(Map.of("error", "Analysis already in progress"));
277-
}
278-
try {
279-
AnalysisResult result = analyzer.run(Path.of(config.getRootPath()), null);
280-
281-
// Persist to Neo4j if GraphStore is available
282-
if (graphStore != null && result.nodes() != null && !result.nodes().isEmpty()) {
283-
graphStore.bulkSave(result.nodes());
284-
}
285-
286-
// Evict all Spring caches so queries pick up new data
287-
if (cacheManager != null) {
288-
cacheManager.getCacheNames().forEach(name -> {
289-
var cache = cacheManager.getCache(name);
290-
if (cache != null) cache.clear();
291-
});
292-
}
293-
294-
Map<String, Object> response = new LinkedHashMap<>();
295-
response.put("status", "complete");
296-
response.put("total_files", result.totalFiles());
297-
response.put("files_analyzed", result.filesAnalyzed());
298-
response.put("node_count", result.nodeCount());
299-
response.put("edge_count", result.edgeCount());
300-
response.put("elapsed_ms", result.elapsed().toMillis());
301-
return ResponseEntity.ok(response);
302-
} finally {
303-
analysisRunning.set(false);
304-
}
305-
}
253+
// POST /api/analyze removed — API/MCP server is read-only.
254+
// Analysis is done locally via CLI: code-iq analyze / code-iq index
255+
// Data is loaded into Neo4j on serve startup (auto-enrich).
306256
}

src/main/java/io/github/randomcodespace/iq/mcp/McpTools.java

Lines changed: 5 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,9 @@
22

33
import com.fasterxml.jackson.core.JsonProcessingException;
44
import com.fasterxml.jackson.databind.ObjectMapper;
5-
import io.github.randomcodespace.iq.analyzer.AnalysisResult;
6-
import io.github.randomcodespace.iq.analyzer.Analyzer;
75
import io.github.randomcodespace.iq.config.CodeIqConfig;
86
import io.github.randomcodespace.iq.flow.FlowEngine;
7+
// Note: No Analyzer import — MCP server is read-only. Analysis is done via CLI only.
98
import io.github.randomcodespace.iq.flow.FlowModels.FlowDiagram;
109
import io.github.randomcodespace.iq.graph.GraphStore;
1110
import io.github.randomcodespace.iq.model.CodeEdge;
@@ -38,7 +37,6 @@
3837
public class McpTools {
3938

4039
private final QueryService queryService;
41-
private final Analyzer analyzer;
4240
private final CodeIqConfig config;
4341
private final ObjectMapper objectMapper;
4442
private final FlowEngine flowEngine;
@@ -47,13 +45,12 @@ public class McpTools {
4745
private final TopologyService topologyService;
4846
private final GraphStore graphStore;
4947

50-
public McpTools(QueryService queryService, Analyzer analyzer,
48+
public McpTools(QueryService queryService,
5149
CodeIqConfig config, ObjectMapper objectMapper,
5250
Optional<FlowEngine> flowEngine, GraphDatabaseService graphDb,
5351
StatsService statsService, TopologyService topologyService,
5452
GraphStore graphStore) {
5553
this.queryService = queryService;
56-
this.analyzer = analyzer;
5754
this.config = config;
5855
this.objectMapper = objectMapper;
5956
this.flowEngine = flowEngine.orElse(null);
@@ -252,30 +249,9 @@ public String generateFlow(
252249
}
253250
}
254251

255-
@McpTool(name = "analyze_codebase", description = "Trigger codebase analysis. Scans files, runs detectors, builds the code graph.")
256-
public String analyzeCodebase(
257-
@McpToolParam(description = "Use incremental analysis", required = false) Boolean incremental) {
258-
try {
259-
boolean useIncremental = incremental != null ? incremental : true;
260-
AnalysisResult result = analyzer.run(Path.of(config.getRootPath()), null, useIncremental, null);
261-
262-
// Persist to Neo4j
263-
if (graphStore != null && result.nodes() != null && !result.nodes().isEmpty()) {
264-
graphStore.bulkSave(result.nodes());
265-
}
266-
267-
Map<String, Object> response = new LinkedHashMap<>();
268-
response.put("status", "complete");
269-
response.put("total_files", result.totalFiles());
270-
response.put("files_analyzed", result.filesAnalyzed());
271-
response.put("node_count", result.nodeCount());
272-
response.put("edge_count", result.edgeCount());
273-
response.put("elapsed_ms", result.elapsed().toMillis());
274-
return toJson(response);
275-
} catch (Exception e) {
276-
return toJson(Map.of("error", e.getMessage()));
277-
}
278-
}
252+
// analyze_codebase removed — MCP server runs on remote hosts where
253+
// source code is not available (only the bundled graph). Analysis is
254+
// done locally via CLI: code-iq analyze / code-iq index
279255

280256
@McpTool(name = "run_cypher", description = "Execute a read-only Cypher query against the Neo4j graph database.")
281257
public String runCypher(

src/test/java/io/github/randomcodespace/iq/api/GraphControllerTest.java

Lines changed: 7 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
package io.github.randomcodespace.iq.api;
22

3-
import io.github.randomcodespace.iq.analyzer.AnalysisResult;
4-
import io.github.randomcodespace.iq.analyzer.Analyzer;
53
import io.github.randomcodespace.iq.config.CodeIqConfig;
64
import io.github.randomcodespace.iq.query.QueryService;
75
import org.junit.jupiter.api.BeforeEach;
@@ -18,7 +16,6 @@
1816
import java.nio.charset.StandardCharsets;
1917
import java.nio.file.Files;
2018
import java.nio.file.Path;
21-
import java.time.Duration;
2219
import java.util.LinkedHashMap;
2320
import java.util.List;
2421
import java.util.Map;
@@ -27,7 +24,6 @@
2724
import static org.mockito.ArgumentMatchers.any;
2825
import static org.mockito.Mockito.when;
2926
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
30-
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
3127
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
3228

3329
/**
@@ -41,9 +37,6 @@ class GraphControllerTest {
4137
@Mock
4238
private QueryService queryService;
4339

44-
@Mock
45-
private Analyzer analyzer;
46-
4740
private CodeIqConfig config;
4841

4942
@BeforeEach
@@ -52,7 +45,7 @@ void setUp() {
5245
config.setMaxDepth(10);
5346
config.setMaxRadius(10);
5447
config.setRootPath(".");
55-
var controller = new GraphController(queryService, analyzer, config, null, null);
48+
var controller = new GraphController(queryService, config);
5649
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
5750
}
5851

@@ -423,7 +416,7 @@ void searchGraphShouldReturnResults() throws Exception {
423416
void readFileShouldReturnContent(@TempDir Path tempDir) throws Exception {
424417
Files.writeString(tempDir.resolve("hello.txt"), "Hello World", StandardCharsets.UTF_8);
425418
config.setRootPath(tempDir.toAbsolutePath().toString());
426-
var controller = new GraphController(queryService, analyzer, config, null, null);
419+
var controller = new GraphController(queryService, config);
427420
var fileMvc = MockMvcBuilders.standaloneSetup(controller).build();
428421

429422
fileMvc.perform(get("/api/file").param("path", "hello.txt"))
@@ -434,7 +427,7 @@ void readFileShouldReturnContent(@TempDir Path tempDir) throws Exception {
434427
@Test
435428
void readFileShouldReturn404ForMissing(@TempDir Path tempDir) throws Exception {
436429
config.setRootPath(tempDir.toAbsolutePath().toString());
437-
var controller = new GraphController(queryService, analyzer, config, null, null);
430+
var controller = new GraphController(queryService, config);
438431
var fileMvc = MockMvcBuilders.standaloneSetup(controller).build();
439432

440433
fileMvc.perform(get("/api/file").param("path", "nonexistent.txt"))
@@ -444,7 +437,7 @@ void readFileShouldReturn404ForMissing(@TempDir Path tempDir) throws Exception {
444437
@Test
445438
void readFileShouldBlockPathTraversal(@TempDir Path tempDir) throws Exception {
446439
config.setRootPath(tempDir.toAbsolutePath().toString());
447-
var controller = new GraphController(queryService, analyzer, config, null, null);
440+
var controller = new GraphController(queryService, config);
448441
var fileMvc = MockMvcBuilders.standaloneSetup(controller).build();
449442

450443
fileMvc.perform(get("/api/file").param("path", "../../../etc/passwd"))
@@ -457,7 +450,7 @@ void readFileShouldReturnLineRange(@TempDir Path tempDir) throws Exception {
457450
Files.writeString(tempDir.resolve("multi.txt"), "line1\nline2\nline3\nline4\nline5",
458451
StandardCharsets.UTF_8);
459452
config.setRootPath(tempDir.toAbsolutePath().toString());
460-
var controller = new GraphController(queryService, analyzer, config, null, null);
453+
var controller = new GraphController(queryService, config);
461454
var fileMvc = MockMvcBuilders.standaloneSetup(controller).build();
462455

463456
fileMvc.perform(get("/api/file")
@@ -472,27 +465,13 @@ void readFileShouldReturnLineRange(@TempDir Path tempDir) throws Exception {
472465
void readFileShouldReturnFullContentWithoutLineParams(@TempDir Path tempDir) throws Exception {
473466
Files.writeString(tempDir.resolve("full.txt"), "aaa\nbbb\nccc", StandardCharsets.UTF_8);
474467
config.setRootPath(tempDir.toAbsolutePath().toString());
475-
var controller = new GraphController(queryService, analyzer, config, null, null);
468+
var controller = new GraphController(queryService, config);
476469
var fileMvc = MockMvcBuilders.standaloneSetup(controller).build();
477470

478471
fileMvc.perform(get("/api/file").param("path", "full.txt"))
479472
.andExpect(status().isOk())
480473
.andExpect(content().string("aaa\nbbb\nccc"));
481474
}
482475

483-
// --- /api/analyze ---
484-
485-
@Test
486-
void triggerAnalysisShouldReturnResult() throws Exception {
487-
var analysisResult = new AnalysisResult(
488-
100, 80, 500, 200, Map.of(), Map.of(), Map.of(), Map.of(), Duration.ofMillis(1500)
489-
);
490-
when(analyzer.run(any(), any())).thenReturn(analysisResult);
491-
492-
mockMvc.perform(post("/api/analyze"))
493-
.andExpect(status().isOk())
494-
.andExpect(jsonPath("$.status").value("complete"))
495-
.andExpect(jsonPath("$.total_files").value(100))
496-
.andExpect(jsonPath("$.node_count").value(500));
497-
}
476+
// POST /api/analyze removed — API is read-only
498477
}

src/test/java/io/github/randomcodespace/iq/api/TopologyEndpointTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ void setUp() {
7474
var topologyController = new TopologyController(topologyService, graphStore, config);
7575
mockMvc = MockMvcBuilders.standaloneSetup(topologyController).build();
7676

77-
mcpTools = new McpTools(queryService, analyzer, config, objectMapper,
77+
mcpTools = new McpTools(queryService, config, objectMapper,
7878
Optional.empty(), graphDb, new StatsService(),
7979
new TopologyService(), graphStore);
8080
}

src/test/java/io/github/randomcodespace/iq/mcp/McpToolsTest.java

Lines changed: 2 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22

33
import com.fasterxml.jackson.core.type.TypeReference;
44
import com.fasterxml.jackson.databind.ObjectMapper;
5-
import io.github.randomcodespace.iq.analyzer.AnalysisResult;
6-
import io.github.randomcodespace.iq.analyzer.Analyzer;
75
import io.github.randomcodespace.iq.config.CodeIqConfig;
86
import io.github.randomcodespace.iq.flow.FlowEngine;
97
import io.github.randomcodespace.iq.flow.FlowModels.FlowDiagram;
@@ -42,9 +40,6 @@ class McpToolsTest {
4240
@Mock
4341
private QueryService queryService;
4442

45-
@Mock
46-
private Analyzer analyzer;
47-
4843
@Mock
4944
private FlowEngine flowEngine;
5045

@@ -66,7 +61,7 @@ void setUp() {
6661
config = new CodeIqConfig();
6762
config.setRootPath(".");
6863
objectMapper = new ObjectMapper();
69-
mcpTools = new McpTools(queryService, analyzer, config, objectMapper, java.util.Optional.ofNullable(flowEngine), graphDb, statsService, new io.github.randomcodespace.iq.query.TopologyService(), graphStore);
64+
mcpTools = new McpTools(queryService, config, objectMapper, java.util.Optional.ofNullable(flowEngine), graphDb, statsService, new io.github.randomcodespace.iq.query.TopologyService(), graphStore);
7065
}
7166

7267
private Map<String, Object> parseJson(String json) throws IOException {
@@ -340,31 +335,7 @@ void generateFlowShouldHandleInvalidView() throws IOException {
340335
assertTrue(parsed.get("error").toString().contains("Unknown view"));
341336
}
342337

343-
// --- analyze_codebase ---
344-
345-
@Test
346-
void analyzeCodebaseShouldReturnResult() throws IOException {
347-
var analysisResult = new AnalysisResult(
348-
100, 80, 500, 200, Map.of(), Map.of(), Map.of(), Map.of(), Duration.ofMillis(1500)
349-
);
350-
when(analyzer.run(any(), any(), anyBoolean(), any())).thenReturn(analysisResult);
351-
352-
String result = mcpTools.analyzeCodebase(false);
353-
Map<String, Object> parsed = parseJson(result);
354-
355-
assertEquals("complete", parsed.get("status"));
356-
assertEquals(500, parsed.get("node_count"));
357-
}
358-
359-
@Test
360-
void analyzeCodebaseShouldHandleError() throws IOException {
361-
when(analyzer.run(any(), any(), anyBoolean(), any())).thenThrow(new RuntimeException("Analysis failed"));
362-
363-
String result = mcpTools.analyzeCodebase(false);
364-
Map<String, Object> parsed = parseJson(result);
365-
366-
assertNotNull(parsed.get("error"));
367-
}
338+
// analyze_codebase removed — MCP is read-only
368339

369340
// --- run_cypher ---
370341

0 commit comments

Comments
 (0)