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:
Martin Vogel
2026-03-02 16:10:57 +01:00
parent 57c8d9c2cb
commit ee9f2b259b
16 changed files with 3000 additions and 299 deletions
+78 -14
View File
@@ -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
+2 -2
View File
@@ -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
+224
View File
@@ -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
}
+36 -7
View File
@@ -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)
+10
View File
@@ -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.
+326
View File
@@ -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
}
+31 -18
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+10 -6
View File
@@ -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
+6 -3
View File
@@ -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
}
+1
View File
@@ -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
View File
@@ -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]
+1 -1
View File
@@ -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,