mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-02 07:53:41 +08:00
Add get_architecture, manage_adr tools; rename Leiden to Louvain
New tools: - get_architecture: codebase architecture overview with 12 selectable aspects (languages, packages, entry_points, routes, hotspots, boundaries, services, layers, clusters, file_tree, adr) - manage_adr: CRUD for Architecture Decision Records with 6 fixed sections, section filtering, validation, and discovery of existing architecture docs Architecture analysis fixes: - Fix qnToPackage extracting segment[2] for meaningful sub-packages instead of segment[1] which collapsed everything to top-level dirs - Filter test functions from entry_points, hotspots, and routes using both is_test property (Module/File nodes) and file_path pattern matching - Filter boundaries to Function/Method/Class nodes only - Add FindArchitectureDocs for discovering existing architecture documentation Naming corrections: - Rename Leiden to Louvain throughout (algorithm is simplified Louvain, not actual Leiden which requires CPM-based refinement) - leiden.go -> louvain.go with all symbols renamed - Remove unused ClusterInfo.Modularity field (always zero) Other changes: - Case-insensitive search by default for search_graph and search_code - Remove read_file and list_directory tools (handled by coding agents natively) - Update tool descriptions and README for 8000 char ADR limit
This commit is contained in:
@@ -4,13 +4,17 @@
|
||||
|
||||
Single Go binary. No Docker, no external databases, no API keys. One command to install, say *"Index this project"* — done.
|
||||
|
||||
Parses source code with [tree-sitter](https://tree-sitter.github.io/tree-sitter/), extracts functions, classes, modules, call relationships, and cross-service HTTP links. Exposes the graph through 14 MCP tools for use with Claude Code, Codex CLI, Cursor, Windsurf, or any MCP-compatible client. Also includes a **CLI mode** for direct tool invocation from the shell — no MCP client needed.
|
||||
Parses source code with [tree-sitter](https://tree-sitter.github.io/tree-sitter/), extracts functions, classes, modules, call relationships, and cross-service HTTP links. Exposes the graph through 12 MCP tools for use with Claude Code, Codex CLI, Cursor, Windsurf, or any MCP-compatible client. Also includes a **CLI mode** for direct tool invocation from the shell — no MCP client needed.
|
||||
|
||||
## Features
|
||||
|
||||
- **35 languages**: Python, Go, JavaScript, TypeScript, TSX, Rust, Java, C++, C#, C, PHP, Lua, Scala, Kotlin, Ruby, Bash, Zig, Elixir, Haskell, OCaml, Objective-C, Swift, Dart, Perl, Groovy, Erlang, R, HTML, CSS, SCSS, YAML, TOML, HCL, SQL, Dockerfile
|
||||
- **Architecture overview**: `get_architecture` returns languages, packages, entry points, routes, hotspots, boundaries, layers, and clusters in a single call — instant codebase orientation
|
||||
- **Architecture Decision Records**: `manage_adr` persists architectural decisions (PURPOSE, STACK, ARCHITECTURE, PATTERNS, TRADEOFFS, PHILOSOPHY) across sessions with section filtering and validation
|
||||
- **Louvain community detection**: Discovers hidden functional modules across packages by clustering CALLS, HTTP_CALLS, and ASYNC_CALLS edges
|
||||
- **Git diff impact mapping**: `detect_changes` maps uncommitted changes to affected graph symbols + blast radius with risk classification (CRITICAL/HIGH/MEDIUM/LOW)
|
||||
- **Risk-classified tracing**: `trace_call_path` with `risk_labels=true` adds impact classification to every node in the call chain
|
||||
- **Case-insensitive search**: `search_graph` and `search_code` are case-insensitive by default — set `case_sensitive=true` for exact matching
|
||||
- **One-command install**: `codebase-memory-mcp install` auto-detects Claude Code, Codex CLI, Cursor, and Windsurf, registers the MCP server, and installs task-specific skills
|
||||
- **Self-update**: `codebase-memory-mcp update` downloads the latest release, verifies checksums, and atomically swaps the binary
|
||||
- **Task-specific skills**: 4 skills (exploring, tracing, quality, reference) that prescribe exact tool sequences — Claude Code automatically uses graph tools instead of defaulting to grep
|
||||
@@ -239,7 +243,7 @@ Add the MCP server to your project's `.mcp.json` (per-project, recommended) or `
|
||||
}
|
||||
```
|
||||
|
||||
Restart Claude Code after adding the config. Verify with `/mcp` — you should see `codebase-memory-mcp` listed with 14 tools.
|
||||
Restart Claude Code after adding the config. Verify with `/mcp` — you should see `codebase-memory-mcp` listed with 12 tools.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -331,22 +335,22 @@ The CLI uses the same SQLite database as the MCP server (`~/.cache/codebase-memo
|
||||
|
||||
| Tool | Key Parameters | Description |
|
||||
|------|---------------|-------------|
|
||||
| `search_graph` | `label`, `name_pattern`, `project`, `file_pattern`, `relationship`, `direction`, `min_degree`, `max_degree`, `exclude_entry_points`, `limit` (default 100), `offset` | Structured search with filters. Use `project` to scope to a single repo when multiple are indexed. Supports pagination via `limit`/`offset` — response includes `has_more` and `total`. |
|
||||
| `search_graph` | `label`, `name_pattern`, `project`, `file_pattern`, `relationship`, `direction`, `min_degree`, `max_degree`, `exclude_entry_points`, `case_sensitive`, `limit` (default 100), `offset` | Structured search with filters. **Case-insensitive by default** (set `case_sensitive=true` for exact case). Use `project` to scope to a single repo when multiple are indexed. Supports pagination via `limit`/`offset` — response includes `has_more` and `total`. |
|
||||
| `trace_call_path` | `function_name` (required), `direction` (inbound/outbound/both), `depth` (1-5, default 3), `risk_labels` (boolean) | BFS traversal from/to a function (exact name match). Returns call chains with signatures, constants, and edge types. Capped at 200 nodes. With `risk_labels=true`, adds CRITICAL/HIGH/MEDIUM/LOW classification and `impact_summary`. |
|
||||
| `detect_changes` | `scope` (unstaged/staged/all/branch), `base_branch`, `depth` (1-5, default 3) | Map git diff to affected graph symbols + blast radius. Returns changed files, changed symbols, and impacted callers with risk classification. Requires git in PATH. |
|
||||
| `query_graph` | `query` (required) | Execute Cypher-like graph queries (read-only). See [Supported Cypher Subset](#supported-cypher-subset) for what's supported. |
|
||||
| `query_graph` | `query` (required) | Execute Cypher-like graph queries (read-only). String matching in WHERE is case-sensitive by default — use `(?i)` flag for case-insensitive regex. See [Supported Cypher Subset](#supported-cypher-subset). |
|
||||
| `get_graph_schema` | — | Node/edge counts, relationship patterns, sample names. Run this first to understand what's in the graph. |
|
||||
| `get_code_snippet` | `qualified_name` (required) | Read source code for a function by its qualified name (reads from disk). See [Qualified Names](#qualified-names) for the format. |
|
||||
| `get_architecture` | `aspects` (array, default `["all"]`), `project` | Codebase architecture overview computed from the code graph. Aspects: `languages`, `packages`, `entry_points`, `routes`, `hotspots`, `boundaries`, `services`, `layers` (heuristic), `clusters` (Louvain community detection), `file_tree`, `adr` (stored Architecture Decision Record). Call with `["all"]` for full orientation. |
|
||||
| `manage_adr` | `mode` (required: `get`/`store`/`update`/`delete`), `project`, `content`, `sections` | CRUD for Architecture Decision Records. `get`: retrieve ADR with parsed sections. `store`: create/replace full ADR (max 8000 chars). `update`: patch specific sections (unmentioned preserved). `delete`: remove ADR. Fixed sections: PURPOSE, STACK, ARCHITECTURE, PATTERNS, TRADEOFFS, PHILOSOPHY. |
|
||||
|
||||
### File Access
|
||||
|
||||
> **Note**: These tools require at least one indexed project. They resolve relative paths against indexed project roots. Index a project first with `index_repository`.
|
||||
> **Note**: File reading and directory listing are handled natively by your coding agent (Claude Code `Read` tool, Codex CLI `cat`/`ls`, etc.). The tools below provide text search within indexed project files.
|
||||
|
||||
| Tool | Key Parameters | Description |
|
||||
|------|---------------|-------------|
|
||||
| `search_code` | `pattern` (required), `file_pattern`, `regex`, `max_results` (default 100), `offset` | Grep-like text search within indexed project files. Supports pagination via `max_results`/`offset`. |
|
||||
| `read_file` | `path`, `start_line`, `end_line` | Read any file from an indexed project. Path can be absolute or relative to project root. |
|
||||
| `list_directory` | `path`, `pattern` | List files/directories with optional glob filtering (e.g. `*.go`, `*.py`). |
|
||||
| `search_code` | `pattern` (required), `file_pattern`, `regex`, `case_sensitive`, `max_results` (default 100), `offset` | Grep-like text search within indexed project files. **Case-insensitive by default** (set `case_sensitive=true` for exact case). Supports pagination via `max_results`/`offset`. |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
@@ -356,10 +360,64 @@ The CLI uses the same SQLite database as the MCP server (`~/.cache/codebase-memo
|
||||
index_repository(repo_path="/path/to/your/project")
|
||||
```
|
||||
|
||||
### Find all functions matching a pattern
|
||||
### Get codebase architecture overview
|
||||
|
||||
```
|
||||
search_graph(label="Function", name_pattern=".*Handler")
|
||||
get_architecture(aspects=["all"])
|
||||
# → languages, packages, entry points, routes, hotspots, boundaries, services, layers, clusters, file tree
|
||||
|
||||
get_architecture(aspects=["languages", "packages"])
|
||||
# → quick orientation — just language breakdown and top packages
|
||||
|
||||
get_architecture(aspects=["hotspots", "boundaries", "clusters"])
|
||||
# → dependency analysis — most-called functions, cross-package calls, community detection
|
||||
```
|
||||
|
||||
### Manage Architecture Decision Records (ADR)
|
||||
|
||||
```
|
||||
# Store a new ADR
|
||||
manage_adr(mode="store", content="## PURPOSE\nOrder processing service\n\n## STACK\n- Go: speed\n- SQLite: embedded storage")
|
||||
|
||||
# Update specific sections (others preserved)
|
||||
manage_adr(mode="update", sections={"PATTERNS": "- Pipeline pattern\n- Repository pattern"})
|
||||
|
||||
# Retrieve the full ADR with parsed sections
|
||||
manage_adr(mode="get")
|
||||
|
||||
# View ADR via architecture overview
|
||||
get_architecture(aspects=["adr"])
|
||||
|
||||
# Delete the ADR
|
||||
manage_adr(mode="delete")
|
||||
```
|
||||
|
||||
### Find all functions matching a pattern
|
||||
|
||||
Search is **case-insensitive by default** — no need for `(?i)`:
|
||||
|
||||
```
|
||||
search_graph(label="Function", name_pattern=".*handler")
|
||||
# → matches "Handler", "handler", "HANDLER", "RequestHandler", etc.
|
||||
|
||||
# Use regex alternatives for broad matching:
|
||||
search_graph(name_pattern="auth|authenticate|authorization")
|
||||
|
||||
# Opt in to exact case matching when needed:
|
||||
search_graph(name_pattern=".*Handler", case_sensitive=true)
|
||||
```
|
||||
|
||||
### Search code (text search)
|
||||
|
||||
```
|
||||
search_code(pattern="TODO")
|
||||
# → case-insensitive by default, matches "TODO", "Todo", "todo"
|
||||
|
||||
search_code(pattern="TODO|FIXME|HACK", regex=true)
|
||||
# → find all issue markers
|
||||
|
||||
search_code(pattern="TODO", case_sensitive=true)
|
||||
# → exact case match only
|
||||
```
|
||||
|
||||
### Trace what a function calls
|
||||
@@ -418,6 +476,11 @@ search_graph(label="Route")
|
||||
query_graph(query="MATCH (f:Function)-[:CALLS]->(g:Function) WHERE f.name = 'main' RETURN g.name, g.qualified_name LIMIT 20")
|
||||
```
|
||||
|
||||
```
|
||||
# Case-insensitive regex in Cypher (use (?i) flag):
|
||||
query_graph(query="MATCH (f:Function) WHERE f.name =~ '(?i).*handler.*' RETURN f.name LIMIT 20")
|
||||
```
|
||||
|
||||
```
|
||||
query_graph(query="MATCH (a)-[r:HTTP_CALLS]->(b) RETURN a.name, b.name, r.url_path, r.confidence LIMIT 10")
|
||||
```
|
||||
@@ -589,7 +652,7 @@ make install # go install
|
||||
|---------|-------|-----|
|
||||
| `/mcp` doesn't show the server | Config not loaded or binary not found | Check `.mcp.json` path is absolute and correct. Restart Claude Code. Verify binary runs: `/path/to/codebase-memory-mcp` should output JSON. |
|
||||
| `index_repository` fails | Missing `repo_path` or path doesn't exist | Pass an absolute path: `index_repository(repo_path="/absolute/path")` |
|
||||
| `read_file` / `list_directory` returns error | No project indexed yet | Run `index_repository` first. These tools resolve paths against indexed project roots. |
|
||||
| `get_architecture` returns empty sections | No project indexed or project has few nodes | Run `index_repository` first. Some aspects (routes, hotspots, clusters) require enough graph data to produce meaningful results. |
|
||||
| `get_code_snippet` returns "node not found" | Wrong qualified name format | Use `search_graph` first to find the exact `qualified_name`, then pass it to `get_code_snippet`. See [Qualified Names](#qualified-names). |
|
||||
| `trace_call_path` returns 0 results | Exact name match — no fuzzy matching | Use `search_graph(name_pattern=".*PartialName.*")` to discover the exact function name first. |
|
||||
| Queries return results from wrong project | Multiple projects indexed, no filter | Add `project="your-project-name"` to `search_graph`. Use `list_projects` to see indexed project names. |
|
||||
@@ -617,17 +680,18 @@ See [`BENCHMARK.md`](BENCHMARK.md) for the full 35-language benchmark with per-q
|
||||
```
|
||||
cmd/codebase-memory-mcp/ Entry point (MCP stdio server + CLI mode + install/update commands)
|
||||
internal/
|
||||
store/ SQLite graph storage (nodes, edges, traversal, search)
|
||||
store/ SQLite graph storage (nodes, edges, traversal, search, architecture, Louvain clustering)
|
||||
lang/ Language specs (35 languages, tree-sitter node types)
|
||||
parser/ Tree-sitter grammar loading and AST parsing
|
||||
pipeline/ 4-pass indexing (structure -> definitions -> calls -> HTTP links)
|
||||
pipeline/ Multi-pass indexing (structure → definitions → calls → HTTP links → communities → tests)
|
||||
httplink/ Cross-service HTTP route/call-site matching
|
||||
cypher/ Cypher query lexer, parser, planner, executor
|
||||
selfupdate/ GitHub release checking, version comparison, asset download
|
||||
tools/ MCP tool handlers (14 tools) + CLI dispatch
|
||||
tools/ MCP tool handlers (12 tools) + CLI dispatch
|
||||
watcher/ Background auto-sync (mtime+size polling, adaptive intervals)
|
||||
discover/ File discovery with .cgrignore support
|
||||
fqn/ Qualified name computation
|
||||
traces/ OpenTelemetry trace ingestion for HTTP_CALLS validation
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/DeusData/codebase-memory-mcp/internal/store"
|
||||
)
|
||||
|
||||
// passCommunities runs Leiden-style community detection on the CALLS graph
|
||||
// passCommunities runs Louvain community detection on the CALLS graph
|
||||
// and creates Community nodes + MEMBER_OF edges.
|
||||
func (p *Pipeline) passCommunities() {
|
||||
slog.Info("pass.communities")
|
||||
@@ -37,7 +37,7 @@ func (p *Pipeline) passCommunities() {
|
||||
adj[e.TargetID][e.SourceID] = true
|
||||
}
|
||||
|
||||
// Run Louvain/Leiden community detection
|
||||
// Run Louvain community detection
|
||||
communities := louvainCommunities(adj, allNodes)
|
||||
|
||||
// Create Community nodes + MEMBER_OF edges
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,224 @@
|
||||
package store
|
||||
|
||||
import "math/rand"
|
||||
|
||||
// louvainEdge represents an edge for the Louvain algorithm.
|
||||
type louvainEdge struct {
|
||||
src int64
|
||||
dst int64
|
||||
edgeType string // for post-processing only
|
||||
}
|
||||
|
||||
// louvain implements community detection using the Louvain algorithm.
|
||||
// Input: node IDs + edges (treated as undirected).
|
||||
// Output: map[nodeID] → communityID.
|
||||
func louvain(nodes []int64, edges []louvainEdge) map[int64]int {
|
||||
const resolution = 1.0
|
||||
if len(nodes) == 0 {
|
||||
return map[int64]int{}
|
||||
}
|
||||
|
||||
// Build compact index: nodeID → sequential index
|
||||
idxOf := make(map[int64]int, len(nodes))
|
||||
for i, id := range nodes {
|
||||
idxOf[id] = i
|
||||
}
|
||||
n := len(nodes)
|
||||
|
||||
// Build adjacency list (undirected, weighted by edge count)
|
||||
adj := make([][]int, n)
|
||||
weight := make([][]float64, n)
|
||||
totalWeight := 0.0
|
||||
|
||||
edgeWeight := map[[2]int]float64{}
|
||||
for _, e := range edges {
|
||||
si, ok1 := idxOf[e.src]
|
||||
di, ok2 := idxOf[e.dst]
|
||||
if !ok1 || !ok2 || si == di {
|
||||
continue
|
||||
}
|
||||
key := [2]int{si, di}
|
||||
if si > di {
|
||||
key = [2]int{di, si}
|
||||
}
|
||||
edgeWeight[key]++
|
||||
}
|
||||
|
||||
for key, w := range edgeWeight {
|
||||
si, di := key[0], key[1]
|
||||
adj[si] = append(adj[si], di)
|
||||
weight[si] = append(weight[si], w)
|
||||
adj[di] = append(adj[di], si)
|
||||
weight[di] = append(weight[di], w)
|
||||
totalWeight += w
|
||||
}
|
||||
|
||||
if totalWeight == 0 {
|
||||
// No edges: each node is its own community
|
||||
result := make(map[int64]int, n)
|
||||
for i, id := range nodes {
|
||||
result[id] = i
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Initialize: each node in its own community
|
||||
community := make([]int, n)
|
||||
for i := range community {
|
||||
community[i] = i
|
||||
}
|
||||
|
||||
// Degree (weighted sum for each node)
|
||||
degree := make([]float64, n)
|
||||
for i := 0; i < n; i++ {
|
||||
for _, w := range weight[i] {
|
||||
degree[i] += w
|
||||
}
|
||||
}
|
||||
|
||||
// Main Louvain loop
|
||||
maxIter := 10
|
||||
for iter := 0; iter < maxIter; iter++ {
|
||||
improved := louvainLocalMoving(n, adj, weight, degree, community, totalWeight, resolution)
|
||||
louvainRefine(n, adj, weight, degree, community, totalWeight, resolution)
|
||||
|
||||
if !improved {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Map back to original IDs
|
||||
result := make(map[int64]int, n)
|
||||
for i, id := range nodes {
|
||||
result[id] = community[i]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// louvainLocalMoving greedily moves each node to the community that maximizes modularity gain.
|
||||
// Returns true if any node was moved.
|
||||
func louvainLocalMoving(n int, adj [][]int, weight [][]float64, degree []float64, community []int, totalWeight, resolution float64) bool {
|
||||
improved := false
|
||||
|
||||
// Community total degree
|
||||
commDegree := make(map[int]float64)
|
||||
for i := 0; i < n; i++ {
|
||||
commDegree[community[i]] += degree[i]
|
||||
}
|
||||
|
||||
// Random order for convergence stability
|
||||
order := make([]int, n)
|
||||
for i := range order {
|
||||
order[i] = i
|
||||
}
|
||||
rand.Shuffle(n, func(i, j int) { order[i], order[j] = order[j], order[i] })
|
||||
|
||||
for _, i := range order {
|
||||
curComm := community[i]
|
||||
|
||||
// Compute weights to each neighboring community
|
||||
neighborComm := map[int]float64{}
|
||||
for j, neighbor := range adj[i] {
|
||||
nc := community[neighbor]
|
||||
neighborComm[nc] += weight[i][j]
|
||||
}
|
||||
|
||||
// Remove node from its community
|
||||
commDegree[curComm] -= degree[i]
|
||||
|
||||
bestComm := curComm
|
||||
bestGain := 0.0
|
||||
|
||||
for comm, wIn := range neighborComm {
|
||||
// Modularity gain formula
|
||||
gain := wIn - resolution*degree[i]*commDegree[comm]/(2*totalWeight)
|
||||
if gain > bestGain {
|
||||
bestGain = gain
|
||||
bestComm = comm
|
||||
}
|
||||
}
|
||||
|
||||
// Also consider staying in current community
|
||||
wInCur := neighborComm[curComm]
|
||||
curGain := wInCur - resolution*degree[i]*commDegree[curComm]/(2*totalWeight)
|
||||
if curGain >= bestGain {
|
||||
bestComm = curComm
|
||||
}
|
||||
|
||||
community[i] = bestComm
|
||||
commDegree[bestComm] += degree[i]
|
||||
|
||||
if bestComm != curComm {
|
||||
improved = true
|
||||
}
|
||||
}
|
||||
|
||||
return improved
|
||||
}
|
||||
|
||||
// louvainRefine checks each community for well-connectedness and potentially splits
|
||||
// poorly-connected ones.
|
||||
func louvainRefine(n int, adj [][]int, weight [][]float64, degree []float64, community []int, totalWeight, resolution float64) {
|
||||
commMembers := map[int][]int{}
|
||||
for i := 0; i < n; i++ {
|
||||
commMembers[community[i]] = append(commMembers[community[i]], i)
|
||||
}
|
||||
for _, members := range commMembers {
|
||||
if len(members) <= 2 {
|
||||
continue
|
||||
}
|
||||
refineCommunity(members, adj, weight, degree, community, n, totalWeight, resolution)
|
||||
}
|
||||
}
|
||||
|
||||
// refineCommunity checks one community for well-connectedness and splits if density is too low.
|
||||
func refineCommunity(members []int, adj [][]int, weight [][]float64, degree []float64, community []int, n int, totalWeight, resolution float64) {
|
||||
memberSet := map[int]bool{}
|
||||
for _, m := range members {
|
||||
memberSet[m] = true
|
||||
}
|
||||
|
||||
var internalWeight float64
|
||||
for _, m := range members {
|
||||
for j, neighbor := range adj[m] {
|
||||
if memberSet[neighbor] {
|
||||
internalWeight += weight[m][j]
|
||||
}
|
||||
}
|
||||
}
|
||||
internalWeight /= 2 // each edge counted twice
|
||||
|
||||
maxInternal := float64(len(members)*(len(members)-1)) / 2
|
||||
if maxInternal == 0 {
|
||||
return
|
||||
}
|
||||
density := internalWeight / maxInternal
|
||||
if density >= 0.01 || len(members) <= 5 {
|
||||
return
|
||||
}
|
||||
|
||||
nextComm := maxCommunity(community, n) + 1
|
||||
for _, m := range members {
|
||||
var wInternal float64
|
||||
for j, neighbor := range adj[m] {
|
||||
if memberSet[neighbor] {
|
||||
wInternal += weight[m][j]
|
||||
}
|
||||
}
|
||||
expectedInternal := resolution * degree[m] * (internalWeight * 2 / totalWeight)
|
||||
if wInternal < expectedInternal*0.5 {
|
||||
community[m] = nextComm
|
||||
nextComm++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func maxCommunity(community []int, n int) int {
|
||||
maxVal := 0
|
||||
for i := 0; i < n; i++ {
|
||||
if community[i] > maxVal {
|
||||
maxVal = community[i]
|
||||
}
|
||||
}
|
||||
return maxVal
|
||||
}
|
||||
@@ -24,6 +24,7 @@ type SearchParams struct {
|
||||
IncludeConnected bool // when true, load connected node names (expensive, off by default)
|
||||
ExcludeLabels []string // labels to exclude from results
|
||||
SortBy string // "relevance" (default), "name", "degree"
|
||||
CaseSensitive bool // false (zero value) = case-insensitive by default
|
||||
}
|
||||
|
||||
// SearchResult is a node with edge degree info.
|
||||
@@ -125,7 +126,7 @@ func buildSearchConditions(params *SearchParams) (conditions []string, args []an
|
||||
// pre-filtering. This drastically reduces the rows Go needs to regex-match.
|
||||
// The full regex is still applied in Go for correctness.
|
||||
if params.NamePattern != "" {
|
||||
if cond, condArgs := buildLikeHintCondition(params.NamePattern, "n.name"); cond != "" {
|
||||
if cond, condArgs := buildLikeHintCondition(params.NamePattern, "n.name", params.CaseSensitive); cond != "" {
|
||||
nameHasLikeHints = true
|
||||
conditions = append(conditions, cond)
|
||||
args = append(args, condArgs...)
|
||||
@@ -134,7 +135,7 @@ func buildSearchConditions(params *SearchParams) (conditions []string, args []an
|
||||
|
||||
// QN pattern: same LIKE hint optimization but against qualified_name only
|
||||
if params.QNPattern != "" {
|
||||
if cond, condArgs := buildLikeHintCondition(params.QNPattern, "n.qualified_name"); cond != "" {
|
||||
if cond, condArgs := buildLikeHintCondition(params.QNPattern, "n.qualified_name", params.CaseSensitive); cond != "" {
|
||||
qnHasLikeHints = true
|
||||
conditions = append(conditions, cond)
|
||||
args = append(args, condArgs...)
|
||||
@@ -146,15 +147,22 @@ func buildSearchConditions(params *SearchParams) (conditions []string, args []an
|
||||
|
||||
// buildLikeHintCondition extracts literal substrings from a regex pattern and returns
|
||||
// a SQL condition with LIKE clauses for pre-filtering. Returns empty string if no hints.
|
||||
func buildLikeHintCondition(pattern, column string) (condition string, args []any) {
|
||||
hints := extractLikeHints(pattern)
|
||||
// When caseSensitive is false, appends COLLATE NOCASE to each LIKE clause.
|
||||
func buildLikeHintCondition(pattern, column string, caseSensitive bool) (condition string, args []any) {
|
||||
// Strip (?i) prefix before extracting hints — metacharacters confuse extractLikeHints
|
||||
cleanPattern := stripCaseFlag(pattern)
|
||||
hints := extractLikeHints(cleanPattern)
|
||||
if len(hints) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
collate := ""
|
||||
if !caseSensitive {
|
||||
collate = " COLLATE NOCASE"
|
||||
}
|
||||
likeParts := make([]string, 0, len(hints))
|
||||
for _, hint := range hints {
|
||||
likeVal := "%" + hint + "%"
|
||||
likeParts = append(likeParts, column+" LIKE ?")
|
||||
likeParts = append(likeParts, column+" LIKE ?"+collate)
|
||||
args = append(args, likeVal)
|
||||
}
|
||||
return "(" + strings.Join(likeParts, " AND ") + ")", args
|
||||
@@ -214,13 +222,21 @@ func (s *Store) executeSearchQuery(where string, args []any, sqlLimit int) ([]*N
|
||||
func applyPatternFilters(nodes []*Node, params *SearchParams) ([]*Node, error) {
|
||||
var err error
|
||||
if params.NamePattern != "" {
|
||||
nodes, err = filterByNamePattern(nodes, params.NamePattern)
|
||||
pattern := params.NamePattern
|
||||
if !params.CaseSensitive {
|
||||
pattern = ensureCaseInsensitive(pattern)
|
||||
}
|
||||
nodes, err = filterByNamePattern(nodes, pattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if params.QNPattern != "" {
|
||||
nodes, err = filterByQNPattern(nodes, params.QNPattern)
|
||||
pattern := params.QNPattern
|
||||
if !params.CaseSensitive {
|
||||
pattern = ensureCaseInsensitive(pattern)
|
||||
}
|
||||
nodes, err = filterByQNPattern(nodes, pattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -528,6 +544,19 @@ func filterByNamePattern(nodes []*Node, pattern string) ([]*Node, error) {
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// ensureCaseInsensitive prepends (?i) to a regex pattern if not already present.
|
||||
func ensureCaseInsensitive(pattern string) string {
|
||||
if strings.HasPrefix(pattern, "(?i)") {
|
||||
return pattern
|
||||
}
|
||||
return "(?i)" + pattern
|
||||
}
|
||||
|
||||
// stripCaseFlag removes a leading (?i) prefix from a regex pattern.
|
||||
func stripCaseFlag(pattern string) string {
|
||||
return strings.TrimPrefix(pattern, "(?i)")
|
||||
}
|
||||
|
||||
// filterByQNPattern filters nodes by a regex pattern against the qualified name.
|
||||
func filterByQNPattern(nodes []*Node, pattern string) ([]*Node, error) {
|
||||
re, err := regexp.Compile(pattern)
|
||||
|
||||
@@ -237,6 +237,16 @@ func (s *Store) initSchema() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Migration: project_summaries table for ADR storage.
|
||||
_, _ = s.db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS project_summaries (
|
||||
project TEXT PRIMARY KEY,
|
||||
summary TEXT NOT NULL,
|
||||
source_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`)
|
||||
|
||||
// Migration: add url_path generated column to edges table.
|
||||
// Generated columns require SQLite 3.31.0+ (mattn/go-sqlite3 supports this).
|
||||
// We check if the column already exists to make this idempotent.
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/DeusData/codebase-memory-mcp/internal/store"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// validArchAspects lists all recognized aspect names.
|
||||
var validArchAspects = map[string]bool{
|
||||
"all": true, "languages": true, "packages": true, "entry_points": true,
|
||||
"routes": true, "hotspots": true, "boundaries": true, "services": true,
|
||||
"layers": true, "clusters": true, "file_tree": true, "adr": true,
|
||||
}
|
||||
|
||||
func (s *Server) handleGetArchitecture(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args, err := parseArgs(req)
|
||||
if err != nil {
|
||||
return errResult(err.Error()), nil
|
||||
}
|
||||
|
||||
aspects, validErr := parseAspects(args)
|
||||
if validErr != nil {
|
||||
return errResult(validErr.Error()), nil
|
||||
}
|
||||
|
||||
project := getStringArg(args, "project")
|
||||
st, err := s.resolveStore(project)
|
||||
if err != nil {
|
||||
return errResult(fmt.Sprintf("resolve store: %v", err)), nil
|
||||
}
|
||||
|
||||
projName := s.resolveProjectName(project)
|
||||
projects, _ := st.ListProjects()
|
||||
if len(projects) > 0 {
|
||||
projName = projects[0].Name
|
||||
}
|
||||
|
||||
info, err := st.GetArchitecture(projName, aspects)
|
||||
if err != nil {
|
||||
return errResult(fmt.Sprintf("architecture: %v", err)), nil
|
||||
}
|
||||
|
||||
responseData := buildArchResponse(projName, info)
|
||||
addADRToResponse(responseData, aspects, st, projName)
|
||||
|
||||
s.addIndexStatus(responseData)
|
||||
result := jsonResult(responseData)
|
||||
s.addUpdateNotice(result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// parseAspects extracts and validates the aspects array from tool arguments.
|
||||
func parseAspects(args map[string]any) ([]string, error) {
|
||||
rawAspects, ok := args["aspects"]
|
||||
if !ok {
|
||||
return []string{"all"}, nil
|
||||
}
|
||||
arr, ok := rawAspects.([]any)
|
||||
if !ok {
|
||||
return []string{"all"}, nil
|
||||
}
|
||||
var aspects []string
|
||||
for _, a := range arr {
|
||||
str, ok := a.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !validArchAspects[str] {
|
||||
return nil, fmt.Errorf("unknown aspect: %q", str)
|
||||
}
|
||||
aspects = append(aspects, str)
|
||||
}
|
||||
if len(aspects) == 0 {
|
||||
return []string{"all"}, nil
|
||||
}
|
||||
return aspects, nil
|
||||
}
|
||||
|
||||
// buildArchResponse converts ArchitectureInfo fields into a response map,
|
||||
// including only non-nil aspects.
|
||||
func buildArchResponse(projName string, info *store.ArchitectureInfo) map[string]any {
|
||||
data := map[string]any{"project": projName}
|
||||
if info.Languages != nil {
|
||||
data["languages"] = info.Languages
|
||||
}
|
||||
if info.Packages != nil {
|
||||
data["packages"] = info.Packages
|
||||
}
|
||||
if info.EntryPoints != nil {
|
||||
data["entry_points"] = info.EntryPoints
|
||||
}
|
||||
if info.Routes != nil {
|
||||
data["routes"] = info.Routes
|
||||
}
|
||||
if info.Hotspots != nil {
|
||||
data["hotspots"] = info.Hotspots
|
||||
}
|
||||
if info.Boundaries != nil {
|
||||
data["boundaries"] = info.Boundaries
|
||||
}
|
||||
if info.Services != nil {
|
||||
data["services"] = info.Services
|
||||
}
|
||||
if info.Layers != nil {
|
||||
data["layers"] = info.Layers
|
||||
}
|
||||
if info.Clusters != nil {
|
||||
data["clusters"] = info.Clusters
|
||||
}
|
||||
if info.FileTree != nil {
|
||||
data["file_tree"] = info.FileTree
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// addADRToResponse includes the stored ADR in the response when requested.
|
||||
func addADRToResponse(data map[string]any, aspects []string, st *store.Store, projName string) {
|
||||
wantADR := false
|
||||
for _, a := range aspects {
|
||||
if a == "adr" || a == "all" {
|
||||
wantADR = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !wantADR {
|
||||
return
|
||||
}
|
||||
adr, getErr := st.GetADR(projName)
|
||||
if getErr != nil || adr == nil {
|
||||
data["adr"] = nil
|
||||
hint := "No ADR yet. Create one with manage_adr(mode='store', content='## PURPOSE\\n...\\n\\n## STACK\\n...'). For guided creation: explore the codebase, enter plan mode, draft collaboratively, then store. Sections: PURPOSE, STACK, ARCHITECTURE, PATTERNS, TRADEOFFS, PHILOSOPHY."
|
||||
if docs, err := st.FindArchitectureDocs(projName); err == nil && len(docs) > 0 {
|
||||
hint += fmt.Sprintf(" Existing architecture docs found: %v — consider reading these first.", docs)
|
||||
}
|
||||
data["adr_hint"] = hint
|
||||
return
|
||||
}
|
||||
data["adr"] = map[string]any{
|
||||
"text": adr.Content,
|
||||
"updated_at": adr.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleManageADR(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args, err := parseArgs(req)
|
||||
if err != nil {
|
||||
return errResult(err.Error()), nil
|
||||
}
|
||||
|
||||
mode := getStringArg(args, "mode")
|
||||
if mode == "" {
|
||||
return errResult("mode is required ('get', 'store', 'update', or 'delete')"), nil
|
||||
}
|
||||
|
||||
project := getStringArg(args, "project")
|
||||
st, err := s.resolveStore(project)
|
||||
if err != nil {
|
||||
return errResult(fmt.Sprintf("resolve store: %v", err)), nil
|
||||
}
|
||||
|
||||
projName := s.resolveProjectName(project)
|
||||
projects, _ := st.ListProjects()
|
||||
if len(projects) > 0 {
|
||||
projName = projects[0].Name
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case "get":
|
||||
include := parseStringArray(args, "include")
|
||||
if err := validateSectionFilter(include); err != nil {
|
||||
return errResult(err.Error()), nil
|
||||
}
|
||||
return s.handleADRGet(st, projName, include)
|
||||
case "store":
|
||||
content := getStringArg(args, "content")
|
||||
return s.handleADRStore(st, projName, content)
|
||||
case "update":
|
||||
sections := getMapStringArg(args, "sections")
|
||||
return s.handleADRUpdate(st, projName, sections)
|
||||
case "delete":
|
||||
return s.handleADRDelete(st, projName)
|
||||
default:
|
||||
return errResult(fmt.Sprintf("invalid mode: %q (use 'get', 'store', 'update', or 'delete')", mode)), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleADRGet(st *store.Store, projName string, include []string) (*mcp.CallToolResult, error) {
|
||||
adr, err := st.GetADR(projName)
|
||||
if err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("get ADR: %w", err)
|
||||
}
|
||||
hint := "No ADR yet. Create one with manage_adr(mode='store', content='## PURPOSE\\n...\\n\\n## STACK\\n...\\n\\n## ARCHITECTURE\\n...\\n\\n## PATTERNS\\n...\\n\\n## TRADEOFFS\\n...\\n\\n## PHILOSOPHY\\n...'). All 6 sections required: PURPOSE, STACK, ARCHITECTURE, PATTERNS, TRADEOFFS, PHILOSOPHY."
|
||||
if docs, findErr := st.FindArchitectureDocs(projName); findErr == nil && len(docs) > 0 {
|
||||
hint += fmt.Sprintf(" Existing architecture docs found: %v — consider reading these first.", docs)
|
||||
}
|
||||
return jsonResult(map[string]any{
|
||||
"project": projName,
|
||||
"adr": nil,
|
||||
"adr_hint": hint,
|
||||
}), nil
|
||||
}
|
||||
|
||||
sections := store.ParseADRSections(adr.Content)
|
||||
|
||||
const alignHint = "If you are drafting or finalizing a plan, validate it against the ADR: " +
|
||||
"check ARCHITECTURE for structural fit, PATTERNS for convention compliance, " +
|
||||
"STACK for technology alignment, and PHILOSOPHY for principle adherence. " +
|
||||
"Flag any conflicts before proceeding."
|
||||
|
||||
// Filter sections if include list is provided.
|
||||
if len(include) > 0 {
|
||||
filtered := make(map[string]string, len(include))
|
||||
for _, name := range include {
|
||||
if content, ok := sections[name]; ok {
|
||||
filtered[name] = content
|
||||
}
|
||||
}
|
||||
return jsonResult(map[string]any{
|
||||
"project": projName,
|
||||
"sections": filtered,
|
||||
"updated_at": adr.UpdatedAt,
|
||||
"alignment_hint": alignHint,
|
||||
}), nil
|
||||
}
|
||||
|
||||
return jsonResult(map[string]any{
|
||||
"project": projName,
|
||||
"sections": sections,
|
||||
"text": adr.Content,
|
||||
"updated_at": adr.UpdatedAt,
|
||||
"alignment_hint": alignHint,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (s *Server) handleADRStore(st *store.Store, projName, content string) (*mcp.CallToolResult, error) {
|
||||
if content == "" {
|
||||
return errResult("content is required for mode='store'"), nil
|
||||
}
|
||||
if len(content) > store.MaxADRLength() {
|
||||
return errResult(fmt.Sprintf("ADR too long (%d chars, max %d)", len(content), store.MaxADRLength())), nil
|
||||
}
|
||||
if err := store.ValidateADRContent(content); err != nil {
|
||||
return errResult(err.Error()), nil
|
||||
}
|
||||
if err := st.StoreADR(projName, content); err != nil {
|
||||
return errResult(fmt.Sprintf("store ADR: %v", err)), nil
|
||||
}
|
||||
return jsonResult(map[string]any{
|
||||
"status": "stored",
|
||||
"project": projName,
|
||||
"updated_at": store.Now(),
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (s *Server) handleADRUpdate(st *store.Store, projName string, sections map[string]string) (*mcp.CallToolResult, error) {
|
||||
if len(sections) == 0 {
|
||||
return errResult("sections is required for mode='update' — e.g. {\"PURPOSE\": \"...\", \"STACK\": \"...\"}"), nil
|
||||
}
|
||||
if err := store.ValidateADRSectionKeys(sections); err != nil {
|
||||
return errResult(err.Error()), nil
|
||||
}
|
||||
adr, err := st.UpdateADRSections(projName, sections)
|
||||
if err != nil {
|
||||
return errResult(fmt.Sprintf("update ADR: %v", err)), nil
|
||||
}
|
||||
parsed := store.ParseADRSections(adr.Content)
|
||||
return jsonResult(map[string]any{
|
||||
"status": "updated",
|
||||
"project": projName,
|
||||
"sections": parsed,
|
||||
"text": adr.Content,
|
||||
"updated_at": adr.UpdatedAt,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (s *Server) handleADRDelete(st *store.Store, projName string) (*mcp.CallToolResult, error) {
|
||||
if err := st.DeleteADR(projName); err != nil {
|
||||
return errResult(fmt.Sprintf("delete ADR: %v", err)), nil
|
||||
}
|
||||
return jsonResult(map[string]any{
|
||||
"status": "deleted",
|
||||
"project": projName,
|
||||
}), nil
|
||||
}
|
||||
|
||||
// parseStringArray extracts a string array from tool arguments.
|
||||
func parseStringArray(args map[string]any, key string) []string {
|
||||
raw, ok := args[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
arr, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var result []string
|
||||
for _, v := range arr {
|
||||
if s, ok := v.(string); ok {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// validateSectionFilter checks that all names in the include filter are canonical sections.
|
||||
func validateSectionFilter(include []string) error {
|
||||
if len(include) == 0 {
|
||||
return nil
|
||||
}
|
||||
return store.ValidateADRSectionKeys(stringsToMap(include))
|
||||
}
|
||||
|
||||
// stringsToMap converts a string slice to a map[string]string for key validation.
|
||||
func stringsToMap(ss []string) map[string]string {
|
||||
m := make(map[string]string, len(ss))
|
||||
for _, s := range ss {
|
||||
m[s] = ""
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -21,13 +21,14 @@ type codeMatch struct {
|
||||
|
||||
// searchCodeParams holds parsed parameters for a code search request.
|
||||
type searchCodeParams struct {
|
||||
pattern string
|
||||
fileGlob string
|
||||
maxResults int
|
||||
offset int
|
||||
isRegex bool
|
||||
re *regexp.Regexp
|
||||
project string
|
||||
pattern string
|
||||
fileGlob string
|
||||
maxResults int
|
||||
offset int
|
||||
isRegex bool
|
||||
caseSensitive bool
|
||||
re *regexp.Regexp
|
||||
project string
|
||||
}
|
||||
|
||||
// parseSearchCodeParams extracts and validates search_code parameters from the request.
|
||||
@@ -38,12 +39,13 @@ func parseSearchCodeParams(req *mcp.CallToolRequest) (*searchCodeParams, *mcp.Ca
|
||||
}
|
||||
|
||||
p := &searchCodeParams{
|
||||
pattern: getStringArg(args, "pattern"),
|
||||
fileGlob: getStringArg(args, "file_pattern"),
|
||||
maxResults: getIntArg(args, "max_results", 10),
|
||||
offset: getIntArg(args, "offset", 0),
|
||||
isRegex: getBoolArg(args, "regex"),
|
||||
project: getStringArg(args, "project"),
|
||||
pattern: getStringArg(args, "pattern"),
|
||||
fileGlob: getStringArg(args, "file_pattern"),
|
||||
maxResults: getIntArg(args, "max_results", 10),
|
||||
offset: getIntArg(args, "offset", 0),
|
||||
isRegex: getBoolArg(args, "regex"),
|
||||
caseSensitive: getBoolArg(args, "case_sensitive"),
|
||||
project: getStringArg(args, "project"),
|
||||
}
|
||||
|
||||
if p.pattern == "" {
|
||||
@@ -51,10 +53,17 @@ func parseSearchCodeParams(req *mcp.CallToolRequest) (*searchCodeParams, *mcp.Ca
|
||||
}
|
||||
|
||||
if p.isRegex {
|
||||
p.re, err = regexp.Compile(p.pattern)
|
||||
pattern := p.pattern
|
||||
if !p.caseSensitive && !strings.HasPrefix(pattern, "(?i)") {
|
||||
pattern = "(?i)" + pattern
|
||||
}
|
||||
p.re, err = regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return nil, errResult(fmt.Sprintf("invalid regex: %v", err))
|
||||
}
|
||||
} else if !p.caseSensitive {
|
||||
// For literal mode, lowercase the pattern; matching done case-insensitively
|
||||
p.pattern = strings.ToLower(p.pattern)
|
||||
}
|
||||
|
||||
return p, nil
|
||||
@@ -83,7 +92,7 @@ func (s *Server) handleSearchCode(_ context.Context, req *mcp.CallToolRequest) (
|
||||
}
|
||||
|
||||
absPath := filepath.Join(root, relPath)
|
||||
fileMatches := searchFile(absPath, relPath, params.pattern, params.re, params.isRegex, fetchLimit-len(allMatches))
|
||||
fileMatches := searchFile(absPath, relPath, params.pattern, params.re, params.isRegex, params.caseSensitive, fetchLimit-len(allMatches))
|
||||
allMatches = append(allMatches, fileMatches...)
|
||||
}
|
||||
|
||||
@@ -155,7 +164,7 @@ func (s *Server) collectSearchFilePaths(fileGlob, project string) []string {
|
||||
return filePaths
|
||||
}
|
||||
|
||||
func searchFile(absPath, relPath, pattern string, re *regexp.Regexp, isRegex bool, limit int) []codeMatch {
|
||||
func searchFile(absPath, relPath, pattern string, re *regexp.Regexp, isRegex, caseSensitive bool, limit int) []codeMatch {
|
||||
f, err := os.Open(absPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
@@ -172,10 +181,14 @@ func searchFile(absPath, relPath, pattern string, re *regexp.Regexp, isRegex boo
|
||||
line := scanner.Text()
|
||||
|
||||
var found bool
|
||||
if isRegex {
|
||||
switch {
|
||||
case isRegex:
|
||||
found = re.MatchString(line)
|
||||
} else {
|
||||
case caseSensitive:
|
||||
found = strings.Contains(line, pattern)
|
||||
default:
|
||||
// pattern already lowercased in parseSearchCodeParams
|
||||
found = strings.Contains(strings.ToLower(line), pattern)
|
||||
}
|
||||
|
||||
if found {
|
||||
|
||||
+1
-191
@@ -1,196 +1,6 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
func (s *Server) handleReadFile(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args, err := parseArgs(req)
|
||||
if err != nil {
|
||||
return errResult(err.Error()), nil
|
||||
}
|
||||
|
||||
filePath := getStringArg(args, "path")
|
||||
if filePath == "" {
|
||||
return errResult("path is required"), nil
|
||||
}
|
||||
|
||||
startLine := getIntArg(args, "start_line", 0)
|
||||
endLine := getIntArg(args, "end_line", 0)
|
||||
|
||||
// Resolve relative path against project root
|
||||
absPath := filePath
|
||||
if !filepath.IsAbs(filePath) {
|
||||
root, rootErr := s.resolveProjectRoot("")
|
||||
if rootErr != nil {
|
||||
return errResult(fmt.Sprintf("resolve root: %v", rootErr)), nil
|
||||
}
|
||||
absPath = filepath.Join(root, filePath)
|
||||
}
|
||||
|
||||
// Check file exists and is not a directory
|
||||
info, statErr := os.Stat(absPath)
|
||||
if statErr != nil {
|
||||
return errResult(fmt.Sprintf("file not found: %s", absPath)), nil
|
||||
}
|
||||
if info.IsDir() {
|
||||
return errResult("path is a directory, use list_directory instead"), nil
|
||||
}
|
||||
|
||||
// Cap file size at 500KB
|
||||
if info.Size() > 500*1024 {
|
||||
return errResult(fmt.Sprintf("file too large (%d bytes, max 500KB). Use start_line/end_line to read a portion", info.Size())), nil
|
||||
}
|
||||
|
||||
f, err := os.Open(absPath)
|
||||
if err != nil {
|
||||
return errResult(fmt.Sprintf("open: %v", err)), nil
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var lines []string
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // 1MB line buffer
|
||||
lineNum := 0
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
if startLine > 0 && lineNum < startLine {
|
||||
continue
|
||||
}
|
||||
if endLine > 0 && lineNum > endLine {
|
||||
break
|
||||
}
|
||||
line := scanner.Text()
|
||||
if len(line) > 500 {
|
||||
line = line[:500] + "..."
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%4d | %s", lineNum, line))
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return errResult(fmt.Sprintf("read: %v", err)), nil
|
||||
}
|
||||
|
||||
result := map[string]any{
|
||||
"path": absPath,
|
||||
"total_lines": lineNum,
|
||||
"content": strings.Join(lines, "\n"),
|
||||
}
|
||||
if startLine > 0 || endLine > 0 {
|
||||
result["range"] = fmt.Sprintf("%d-%d", startLine, endLine)
|
||||
}
|
||||
|
||||
return jsonResult(result), nil
|
||||
}
|
||||
|
||||
func (s *Server) handleListDirectory(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args, err := parseArgs(req)
|
||||
if err != nil {
|
||||
return errResult(err.Error()), nil
|
||||
}
|
||||
|
||||
dirPath := getStringArg(args, "path")
|
||||
globPattern := getStringArg(args, "pattern")
|
||||
|
||||
// Resolve relative path against project root
|
||||
absPath := dirPath
|
||||
if dirPath == "" || !filepath.IsAbs(dirPath) {
|
||||
project := getStringArg(args, "project")
|
||||
root, rootErr := s.resolveProjectRoot(project)
|
||||
if rootErr != nil {
|
||||
return errResult(fmt.Sprintf("resolve root: %v", rootErr)), nil
|
||||
}
|
||||
if dirPath == "" {
|
||||
absPath = root
|
||||
} else {
|
||||
absPath = filepath.Join(root, dirPath)
|
||||
}
|
||||
}
|
||||
|
||||
info, _ := os.Stat(absPath)
|
||||
if info == nil {
|
||||
return errResult(fmt.Sprintf("path not found: %s", absPath)), nil
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return errResult("path is a file, use read_file instead"), nil
|
||||
}
|
||||
|
||||
entries, listErr := listEntries(absPath, globPattern)
|
||||
if listErr != nil {
|
||||
return errResult(listErr.Error()), nil
|
||||
}
|
||||
|
||||
return jsonResult(map[string]any{
|
||||
"directory": absPath,
|
||||
"count": len(entries),
|
||||
"entries": entries,
|
||||
}), nil
|
||||
}
|
||||
|
||||
type dirEntry struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
}
|
||||
|
||||
func listEntries(absPath, globPattern string) ([]dirEntry, error) {
|
||||
if globPattern != "" {
|
||||
return listGlobEntries(absPath, globPattern)
|
||||
}
|
||||
return listDirChildren(absPath)
|
||||
}
|
||||
|
||||
func listGlobEntries(absPath, globPattern string) ([]dirEntry, error) {
|
||||
matches, globErr := filepath.Glob(filepath.Join(absPath, globPattern))
|
||||
if globErr != nil {
|
||||
return nil, fmt.Errorf("glob: %w", globErr)
|
||||
}
|
||||
entries := make([]dirEntry, 0, len(matches))
|
||||
for _, m := range matches {
|
||||
fi, _ := os.Stat(m)
|
||||
if fi == nil {
|
||||
continue
|
||||
}
|
||||
relPath, _ := filepath.Rel(absPath, m)
|
||||
e := dirEntry{Name: relPath, Path: m, IsDir: fi.IsDir()}
|
||||
if !fi.IsDir() {
|
||||
e.Size = fi.Size()
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func listDirChildren(absPath string) ([]dirEntry, error) {
|
||||
dirEntries, readErr := os.ReadDir(absPath)
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("read dir: %w", readErr)
|
||||
}
|
||||
entries := make([]dirEntry, 0, len(dirEntries))
|
||||
for _, de := range dirEntries {
|
||||
if strings.HasPrefix(de.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
fi, _ := de.Info()
|
||||
if fi == nil {
|
||||
continue
|
||||
}
|
||||
e := dirEntry{Name: de.Name(), Path: filepath.Join(absPath, de.Name()), IsDir: de.IsDir()}
|
||||
if !de.IsDir() {
|
||||
e.Size = fi.Size()
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
import "fmt"
|
||||
|
||||
// resolveProjectRoot finds a project root path by name or from session.
|
||||
func (s *Server) resolveProjectRoot(project string) (string, error) {
|
||||
|
||||
+11
-2
@@ -63,10 +63,19 @@ func (s *Server) handleIndexRepository(ctx context.Context, req *mcp.CallToolReq
|
||||
indexedAt = proj.IndexedAt
|
||||
}
|
||||
|
||||
return jsonResult(map[string]any{
|
||||
result := map[string]any{
|
||||
"project": projectName,
|
||||
"nodes": nodeCount,
|
||||
"edges": edgeCount,
|
||||
"indexed_at": indexedAt,
|
||||
}), nil
|
||||
}
|
||||
|
||||
// Check for ADR presence and suggest creation if missing
|
||||
adr, _ := st.GetADR(projectName)
|
||||
result["adr_present"] = adr != nil
|
||||
if adr == nil {
|
||||
result["adr_hint"] = "Project indexed. Consider creating an Architecture Decision Record: explore the codebase with get_architecture(aspects=['all']), then use manage_adr(mode='store') to persist architectural insights across sessions."
|
||||
}
|
||||
|
||||
return jsonResult(result), nil
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ func (s *Server) handleListProjects(_ context.Context, _ *mcp.CallToolRequest) (
|
||||
Nodes int `json:"nodes"`
|
||||
Edges int `json:"edges"`
|
||||
DBPath string `json:"db_path"`
|
||||
ADRPresent bool `json:"adr_present"`
|
||||
IsSessionProject bool `json:"is_session_project,omitempty"`
|
||||
}
|
||||
|
||||
@@ -43,13 +44,16 @@ func (s *Server) handleListProjects(_ context.Context, _ *mcp.CallToolRequest) (
|
||||
rootPath = proj.RootPath
|
||||
}
|
||||
|
||||
adr, _ := st.GetADR(info.Name)
|
||||
|
||||
entry := projectEntry{
|
||||
Name: info.Name,
|
||||
RootPath: rootPath,
|
||||
IndexedAt: indexedAt,
|
||||
Nodes: nc,
|
||||
Edges: ec,
|
||||
DBPath: info.DBPath,
|
||||
Name: info.Name,
|
||||
RootPath: rootPath,
|
||||
IndexedAt: indexedAt,
|
||||
Nodes: nc,
|
||||
Edges: ec,
|
||||
DBPath: info.DBPath,
|
||||
ADRPresent: adr != nil,
|
||||
}
|
||||
if info.Name == s.sessionProject {
|
||||
entry.IsSessionProject = true
|
||||
|
||||
@@ -32,11 +32,14 @@ func (s *Server) handleGetGraphSchema(_ context.Context, req *mcp.CallToolReques
|
||||
}
|
||||
|
||||
type projectSchema struct {
|
||||
Project string `json:"project"`
|
||||
Schema *store.SchemaInfo `json:"schema"`
|
||||
Project string `json:"project"`
|
||||
Schema *store.SchemaInfo `json:"schema"`
|
||||
ADRPresent bool `json:"adr_present"`
|
||||
}
|
||||
|
||||
adr, _ := st.GetADR(projName)
|
||||
|
||||
return jsonResult(map[string]any{
|
||||
"projects": []projectSchema{{Project: projName, Schema: schema}},
|
||||
"projects": []projectSchema{{Project: projName, Schema: schema, ADRPresent: adr != nil}},
|
||||
}), nil
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ func (s *Server) handleSearchGraph(_ context.Context, req *mcp.CallToolRequest)
|
||||
Offset: getIntArg(args, "offset", 0),
|
||||
ExcludeEntryPoints: getBoolArg(args, "exclude_entry_points"),
|
||||
IncludeConnected: getBoolArg(args, "include_connected"),
|
||||
CaseSensitive: getBoolArg(args, "case_sensitive"),
|
||||
}
|
||||
|
||||
// Parse exclude_labels array; default to excluding Community nodes
|
||||
|
||||
+89
-55
@@ -366,10 +366,65 @@ func (s *Server) ToolNames() []string {
|
||||
|
||||
func (s *Server) registerTools() {
|
||||
s.registerGraphTools()
|
||||
s.registerFileTools()
|
||||
s.registerProjectTools()
|
||||
s.registerTraceTools()
|
||||
s.registerDetectChanges()
|
||||
s.registerArchitectureTools()
|
||||
}
|
||||
|
||||
func (s *Server) registerArchitectureTools() {
|
||||
s.addTool(&mcp.Tool{
|
||||
Name: "get_architecture",
|
||||
Description: "Get codebase architecture overview. Returns structural analysis computed from the code graph. Call with aspects=['all'] for full orientation or select specific aspects for targeted queries. Available aspects: languages (language breakdown), packages (top packages with fan-in/out), entry_points (main/init functions), routes (HTTP endpoints with handlers), hotspots (most-called functions), boundaries (cross-package call volumes), services (cross-service HTTP/async links), layers (package-level layer classification — heuristic), clusters (community detection via Louvain algorithm on CALLS + HTTP_CALLS + ASYNC_CALLS — reveals hidden functional modules across packages and services), file_tree (condensed directory structure), adr (stored Architecture Decision Record — use manage_adr to create/update). Recommended: call this first when exploring an unfamiliar codebase.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"aspects": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "enum": ["all", "languages", "packages", "entry_points", "routes", "hotspots", "boundaries", "services", "layers", "clusters", "file_tree", "adr"]},
|
||||
"description": "Which architecture aspects to return. Default: ['all']. Use specific aspects to reduce output: ['languages', 'packages'] for quick orientation, ['hotspots', 'boundaries'] for dependency analysis, ['clusters'] for community detection across CALLS/HTTP/ASYNC edges."
|
||||
},
|
||||
"project": {
|
||||
"type": "string",
|
||||
"description": "Project to analyze. Defaults to session project."
|
||||
}
|
||||
}
|
||||
}`),
|
||||
}, s.handleGetArchitecture)
|
||||
|
||||
s.addTool(&mcp.Tool{
|
||||
Name: "manage_adr",
|
||||
Description: "Manage the Architecture Decision Record (ADR) for a project. CRUD operations for a persistent, section-based architectural summary. Modes: get (retrieve full ADR or specific sections via include filter), store (create or fully replace — all 6 sections required), update (patch specific sections — only canonical keys accepted, unmentioned sections preserved), delete (remove ADR). Fixed sections in canonical order: PURPOSE, STACK, ARCHITECTURE, PATTERNS, TRADEOFFS, PHILOSOPHY. Max 8000 chars total. Validation: store rejects content missing any section; update rejects non-canonical keys. Use include=['STACK','PATTERNS'] with get to fetch only needed sections (saves tokens). PLAN ALIGNMENT: Before finalizing any implementation plan, fetch the ADR and validate the plan against it — check ARCHITECTURE for structural fit, PATTERNS for convention compliance, STACK for technology alignment, PHILOSOPHY for principle adherence. Flag conflicts before proceeding. For creating/replacing: explore the codebase first using get_architecture and graph tools, then enter plan mode to draft the ADR collaboratively with the user. Store only after user approval.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["get", "store", "update", "delete"],
|
||||
"description": "Operation: 'get' retrieves ADR, 'store' creates/replaces (all 6 sections required), 'update' patches sections (canonical keys only), 'delete' removes."
|
||||
},
|
||||
"project": {
|
||||
"type": "string",
|
||||
"description": "Project name. Defaults to session project."
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Full ADR markdown (required for mode='store'). Must contain all 6 ## SECTION headers: PURPOSE, STACK, ARCHITECTURE, PATTERNS, TRADEOFFS, PHILOSOPHY. Missing sections will be rejected."
|
||||
},
|
||||
"sections": {
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "string"},
|
||||
"description": "Section updates (required for mode='update'). Keys must be canonical section names (PURPOSE, STACK, ARCHITECTURE, PATTERNS, TRADEOFFS, PHILOSOPHY). Non-canonical keys are rejected. Values are new content. Unmentioned sections preserved."
|
||||
},
|
||||
"include": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "enum": ["PURPOSE", "STACK", "ARCHITECTURE", "PATTERNS", "TRADEOFFS", "PHILOSOPHY"]},
|
||||
"description": "Section filter for mode='get'. Returns only the listed sections instead of the full ADR. Example: ['STACK', 'PATTERNS'] returns ~800 chars instead of ~8000. Omit to get all sections."
|
||||
}
|
||||
},
|
||||
"required": ["mode"]
|
||||
}`),
|
||||
}, s.handleManageADR)
|
||||
}
|
||||
|
||||
// registerGraphTools registers tools for graph querying, searching, and tracing.
|
||||
@@ -466,7 +521,7 @@ func (s *Server) registerSchemaAndSnippetTools() {
|
||||
func (s *Server) registerSearchTools() {
|
||||
s.addTool(&mcp.Tool{
|
||||
Name: "search_graph",
|
||||
Description: "Search the code knowledge graph for functions, classes, modules, routes, and other code elements. Returns nodes matching the criteria with their connectivity (in/out degree). Results are sorted by relevance by default (exact match first, prefix match second, then by connectivity). Community nodes are excluded by default. Pass exclude_labels: [] to include them. Best practice: Chain with trace_call_path and get_code_snippet for complete answers. Example workflow: search_graph(name_pattern='.*Order.*') → trace_call_path(function_name='processOrder') → get_code_snippet(qualified_name='...'). Returns 10 results per page (use offset to paginate, has_more indicates more pages). name_pattern and qn_pattern support full Go regex — one precise regex replaces multiple literal searches. See parameter descriptions for patterns. For dead code: use relationship='CALLS', direction='inbound', max_degree=0, exclude_entry_points=true. For fan-out: use relationship='CALLS', direction='outbound', min_degree=N. Route nodes: properties.handler contains the actual handler function name. Prefer this over query_graph for counting — no row cap. IMPORTANT: The 'relationship' filter counts how many edges of that type each node has (degree filtering) — it does NOT return the actual edges. To list cross-service HTTP_CALLS or ASYNC_CALLS edges with their properties, use query_graph with Cypher instead. Relationship types: CALLS, HTTP_CALLS, ASYNC_CALLS, IMPORTS, DEFINES, DEFINES_METHOD, HANDLES, CONTAINS_FILE, CONTAINS_FOLDER, CONTAINS_PACKAGE, IMPLEMENTS, OVERRIDE, USAGE, FILE_CHANGES_WITH.",
|
||||
Description: "Search the code knowledge graph for functions, classes, modules, routes, and other code elements. Search is case-insensitive by default (set case_sensitive=true for exact case). Best practice: use regex alternatives for broad matching — include abbreviations ('handler|hdlr|ctrl'), word forms ('sponsor|sponsoring|sponsored'), and synonyms ('delete|remove|drop'). One broad regex replaces multiple narrow searches. Returns nodes matching the criteria with their connectivity (in/out degree). Results are sorted by relevance by default (exact match first, prefix match second, then by connectivity). Community nodes are excluded by default. Pass exclude_labels: [] to include them. Best practice: Chain with trace_call_path and get_code_snippet for complete answers. Example workflow: search_graph(name_pattern='.*Order.*') → trace_call_path(function_name='processOrder') → get_code_snippet(qualified_name='...'). Returns 10 results per page (use offset to paginate, has_more indicates more pages). name_pattern and qn_pattern support full Go regex — one precise regex replaces multiple literal searches. See parameter descriptions for patterns. For dead code: use relationship='CALLS', direction='inbound', max_degree=0, exclude_entry_points=true. For fan-out: use relationship='CALLS', direction='outbound', min_degree=N. Route nodes: properties.handler contains the actual handler function name. Prefer this over query_graph for counting — no row cap. IMPORTANT: The 'relationship' filter counts how many edges of that type each node has (degree filtering) — it does NOT return the actual edges. To list cross-service HTTP_CALLS or ASYNC_CALLS edges with their properties, use query_graph with Cypher instead. Relationship types: CALLS, HTTP_CALLS, ASYNC_CALLS, IMPORTS, DEFINES, DEFINES_METHOD, HANDLES, CONTAINS_FILE, CONTAINS_FOLDER, CONTAINS_PACKAGE, IMPLEMENTS, OVERRIDE, USAGE, FILE_CHANGES_WITH.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -480,11 +535,11 @@ func (s *Server) registerSearchTools() {
|
||||
},
|
||||
"name_pattern": {
|
||||
"type": "string",
|
||||
"description": "Regex pattern matched against the short node name only. Supports full Go regex: '.*Handler$' (suffix), '(?i)auth' (case-insensitive), 'get|set|delete' (alternatives), '^on[A-Z]' (prefix+char class). Does NOT match against file paths or qualified names — use qn_pattern for path scoping."
|
||||
"description": "Regex pattern matched against the short node name. Case-insensitive by default. Supports full Go regex: '.*Handler$' (suffix), 'get|set|delete' (alternatives — no backslash before pipe), '^on[A-Z]' (prefix+char class). Best practice: include word variations in alternatives — 'auth|authenticate|authorization' (word forms), 'handler|hdlr|ctrl' (abbreviations), 'create|new|init' (synonyms). One regex with | replaces multiple separate searches."
|
||||
},
|
||||
"qn_pattern": {
|
||||
"type": "string",
|
||||
"description": "Regex pattern matched against the qualified name (full module path). Use to scope searches to directories/modules: '.*services\\.order\\..*' (order service), '.*tests\\..*' (test files only), '(?i).*controller.*\\.handle.*' (handler methods in controllers). Combine with name_pattern for precise cross-cutting queries."
|
||||
"description": "Regex pattern matched against the qualified name (full module path). Case-insensitive by default. Use to scope searches to directories/modules: '.*services\\.order\\..*' (order service), '.*tests\\..*' (test files only), '.*controller.*\\.handle.*' (handler methods in controllers). Combine with name_pattern for precise cross-cutting queries."
|
||||
},
|
||||
"file_pattern": {
|
||||
"type": "string",
|
||||
@@ -532,6 +587,10 @@ func (s *Server) registerSearchTools() {
|
||||
"type": "string",
|
||||
"enum": ["relevance", "name", "degree"],
|
||||
"description": "Sort order. Default: relevance (exact match first, prefix match second, then by connectivity)"
|
||||
},
|
||||
"case_sensitive": {
|
||||
"type": "boolean",
|
||||
"description": "Match patterns case-sensitively. Default: false (case-insensitive). Set true for exact case matching."
|
||||
}
|
||||
}
|
||||
}`),
|
||||
@@ -539,13 +598,13 @@ func (s *Server) registerSearchTools() {
|
||||
|
||||
s.addTool(&mcp.Tool{
|
||||
Name: "search_code",
|
||||
Description: "Search for text in source code files (like grep, scoped to indexed project). Returns matching lines with file path, line number, and context. Returns 10 matches per page — use offset to paginate, has_more indicates more pages. Use for: string literals, error messages, TODO comments, config values, import statements. With regex=true, supports full Go regex — see pattern parameter for examples. Prefer search_graph for finding functions/classes by name — search_code is for text content that isn't in the graph.",
|
||||
Description: "Search for text in source code files (like grep, scoped to indexed project). Search is case-insensitive by default (set case_sensitive=true for exact case). With regex=true, use alternatives for broad matching: 'TODO|FIXME|HACK|WORKAROUND' (issue markers), 'sponsor|sponsoring|sponsored' (word forms), 'import|require|include' (cross-language patterns). Returns matching lines with file path, line number, and context. Returns 10 matches per page — use offset to paginate, has_more indicates more pages. Use for: string literals, error messages, TODO comments, config values, import statements. Prefer search_graph for finding functions/classes by name — search_code is for text content that isn't in the graph.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Text to search for. Literal string by default; with regex=true supports full Go regex (e.g. 'err != nil', 'TODO|FIXME', '(?i)deprecated')"
|
||||
"description": "Text to search for. Case-insensitive by default. Literal string match unless regex=true. With regex=true: Go regex syntax (no backslash before pipe). Best practice: use alternatives for word form variance — 'deprecat|obsolete|legacy' catches 'deprecated', 'deprecating', 'obsolete', etc. A partial stem with alternatives is more effective than an exact word."
|
||||
},
|
||||
"file_pattern": {
|
||||
"type": "string",
|
||||
@@ -563,6 +622,10 @@ func (s *Server) registerSearchTools() {
|
||||
"type": "integer",
|
||||
"description": "Skip N matches for pagination (default: 0). Check has_more in response."
|
||||
},
|
||||
"case_sensitive": {
|
||||
"type": "boolean",
|
||||
"description": "Match case-sensitively. Default: false (case-insensitive). Set true for exact case matching."
|
||||
},
|
||||
"project": {
|
||||
"type": "string",
|
||||
"description": "Project to search in. Defaults to session project."
|
||||
@@ -576,7 +639,7 @@ func (s *Server) registerSearchTools() {
|
||||
func (s *Server) registerQueryTool() {
|
||||
s.addTool(&mcp.Tool{
|
||||
Name: "query_graph",
|
||||
Description: "Execute a Cypher-like graph query. WARNING: 200-row cap applies BEFORE aggregation — COUNT queries on large codebases silently undercount. For fan-out/fan-in counting, use search_graph with min_degree/max_degree instead. Best for: relationship patterns, filtered joins, path queries, and edge property filtering. Supports WHERE on edge properties: r.url_path CONTAINS 'orders', r.confidence >= 0.6, r.method = 'POST', r.confidence_band = 'high', r.validated_by_trace = true, r.coupling_score >= 0.5. This is the correct tool for listing cross-service edges — use MATCH (a)-[r:HTTP_CALLS]->(b) RETURN a.name, b.name, r.url_path, r.confidence, r.confidence_band to see HTTP links with URLs and confidence scores (bands: high>=0.7, medium>=0.45, speculative>=0.25), or MATCH (a)-[r:ASYNC_CALLS]->(b) for async dispatch edges. For change coupling: MATCH (a)-[r:FILE_CHANGES_WITH]->(b) RETURN a.name, b.name, r.coupling_score, r.co_change_count. For interface method overrides: MATCH (s)-[r:OVERRIDE]->(i) to find struct methods implementing interface methods. For read references (callbacks, variable assignments): MATCH (a)-[r:USAGE]->(b). Always use LIMIT. Edge types: CALLS, HTTP_CALLS, ASYNC_CALLS, IMPORTS, DEFINES, DEFINES_METHOD, HANDLES, IMPLEMENTS, OVERRIDE, USAGE, FILE_CHANGES_WITH.",
|
||||
Description: "Execute a Cypher-like graph query. String matching in WHERE is case-sensitive by default. For case-insensitive regex: use (?i) flag — WHERE f.name =~ '(?i)handler'. CONTAINS and STARTS WITH are always case-sensitive — use =~ with (?i) for case-insensitive substring matching: WHERE f.name =~ '(?i).*order.*'. Best practice: use regex alternatives for word form variance — WHERE f.name =~ '(?i)sponsor|sponsoring|sponsored'. Tip: prefer search_graph for simple name searches (case-insensitive by default) — use query_graph only when you need Cypher's relationship patterns or edge properties. WARNING: 200-row cap applies BEFORE aggregation — COUNT queries on large codebases silently undercount. For fan-out/fan-in counting, use search_graph with min_degree/max_degree instead. Best for: relationship patterns, filtered joins, path queries, and edge property filtering. Supports WHERE on edge properties: r.url_path CONTAINS 'orders', r.confidence >= 0.6, r.method = 'POST', r.confidence_band = 'high', r.validated_by_trace = true, r.coupling_score >= 0.5. This is the correct tool for listing cross-service edges — use MATCH (a)-[r:HTTP_CALLS]->(b) RETURN a.name, b.name, r.url_path, r.confidence, r.confidence_band to see HTTP links with URLs and confidence scores (bands: high>=0.7, medium>=0.45, speculative>=0.25), or MATCH (a)-[r:ASYNC_CALLS]->(b) for async dispatch edges. For change coupling: MATCH (a)-[r:FILE_CHANGES_WITH]->(b) RETURN a.name, b.name, r.coupling_score, r.co_change_count. For interface method overrides: MATCH (s)-[r:OVERRIDE]->(i) to find struct methods implementing interface methods. For read references (callbacks, variable assignments): MATCH (a)-[r:USAGE]->(b). Always use LIMIT. Edge types: CALLS, HTTP_CALLS, ASYNC_CALLS, IMPORTS, DEFINES, DEFINES_METHOD, HANDLES, IMPLEMENTS, OVERRIDE, USAGE, FILE_CHANGES_WITH.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -594,54 +657,6 @@ func (s *Server) registerQueryTool() {
|
||||
}, s.handleQueryGraph)
|
||||
}
|
||||
|
||||
// registerFileTools registers tools for file and directory operations.
|
||||
func (s *Server) registerFileTools() {
|
||||
s.addTool(&mcp.Tool{
|
||||
Name: "read_file",
|
||||
Description: "Read any file from the indexed project. Supports line range selection for large files. Use for reading config files (Dockerfile, go.mod, requirements.txt), source code, or any text file.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File path (absolute, or relative to project root)"
|
||||
},
|
||||
"start_line": {
|
||||
"type": "integer",
|
||||
"description": "Start reading from this line (1-based, optional)"
|
||||
},
|
||||
"end_line": {
|
||||
"type": "integer",
|
||||
"description": "Stop reading at this line (inclusive, optional)"
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
}`),
|
||||
}, s.handleReadFile)
|
||||
|
||||
s.addTool(&mcp.Tool{
|
||||
Name: "list_directory",
|
||||
Description: "List files and subdirectories in a directory. Supports glob patterns for filtering. Use for exploring project structure.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Directory path (absolute, or relative to project root). Empty for project root."
|
||||
},
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Glob pattern to filter entries (e.g. '*.go', '*.py')"
|
||||
},
|
||||
"project": {
|
||||
"type": "string",
|
||||
"description": "Project name to resolve root from. Defaults to session project."
|
||||
}
|
||||
}
|
||||
}`),
|
||||
}, s.handleListDirectory)
|
||||
}
|
||||
|
||||
// registerProjectTools registers tools for project management.
|
||||
func (s *Server) registerProjectTools() {
|
||||
s.addTool(&mcp.Tool{
|
||||
@@ -743,6 +758,25 @@ func getIntArg(args map[string]any, key string, defaultVal int) int {
|
||||
return int(f)
|
||||
}
|
||||
|
||||
// getMapStringArg extracts a map[string]string argument from parsed args.
|
||||
func getMapStringArg(args map[string]any, key string) map[string]string {
|
||||
v, ok := args[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
result := make(map[string]string, len(m))
|
||||
for k, val := range m {
|
||||
if s, ok := val.(string); ok {
|
||||
result[k] = s
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// getBoolArg extracts a boolean argument from parsed args.
|
||||
func getBoolArg(args map[string]any, key string) bool {
|
||||
v, ok := args[key]
|
||||
|
||||
@@ -252,7 +252,7 @@ func (s *Server) findSimilarNodes(name, project string, limit int) []*store.Node
|
||||
}
|
||||
params := &store.SearchParams{
|
||||
Project: projName,
|
||||
NamePattern: "(?i)" + regexp.QuoteMeta(name),
|
||||
NamePattern: regexp.QuoteMeta(name),
|
||||
Limit: limit,
|
||||
MinDegree: -1,
|
||||
MaxDegree: -1,
|
||||
|
||||
Reference in New Issue
Block a user