Skip to content
40 changes: 38 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,42 @@ Repeat `--root` and `--project` as needed; they can be mixed:
}
```

### Central store

`--store <dir>` re-roots every knowledge store under one central directory,
keeping repos clean of `.agents/`:

```jsonc
// opencode.jsonc
{
"mcp": {
"knowledge-mcp": {
"command": ["knowledge-mcp", "--root", "/path/to/org", "--store", "/path/to/knowledge"],
"type": "local"
}
}
}
```

Layout:

```
<store>/
.index/ # bleve index (default; --index wins)
<project>/.agents/ # per-project store (qualified names nest:
# <root>/<project> -> <store>/<root>/<project>/.agents)
<org>/.agents/knowledge/ # org-level markdown
```

- In-tree `.agents/` dirs are ignored (startup warnings list them). Migrate
by moving them: `mkdir -p <store>/<address> && mv <project>/.agents <store>/<address>/.agents`.
- Only `--global` stays where it is; it merges into listings as usual.
- `init_knowledge` initializes the central store for resolvable paths and
errors for others (add them via `--project`/`--root` first).
- The store directory itself must exist and is never created for you.
- The store is single-writer: its search index and files do not support
concurrent access, so point only one server at a store.

### Shared global store

`--global <path>` adds one knowledge store shared across every project. Querying or listing any project automatically merges the global store's entries alongside the project's own.
Expand Down Expand Up @@ -102,14 +138,14 @@ General notes:
- Each `--root` discovers its immediate children as projects (one level deep — pass deeper directories as additional flags).
- Duplicate project basenames across roots are addressed as `<root>/<project>` (e.g. `work/api`); `list_projects` shows which names need qualification.
- `query_knowledge` accepts org root names to search that root's `.agents/knowledge/` files. Org-level documents are indexed **per `##` section**, so a query returns the matching section(s), not the entire file.
- Multi-entry configurations store the search index under `$XDG_STATE_HOME/knowledge-mcp/` (default `~/.local/state/knowledge-mcp/`). Single-flag configurations keep the index inside their own `.agents/`.
- Multi-entry configurations store the search index under `$XDG_STATE_HOME/knowledge-mcp/` (default `~/.local/state/knowledge-mcp/`). Single-flag configurations keep the index inside their own `.agents/` — unless `--store` is set, in which case the index lives at `<store>/.index`.
- `--index <path>` overrides the index location in all modes.

## MCP Tools

| Tool | Description |
|------|-------------|
| `init_knowledge` | Create `.agents/` directory structure for a project |
| `init_knowledge` | Create a project's knowledge directory structure (central store under `--store`) |
| `write_knowledge` | Add a new knowledge entry |
| `query_knowledge` | Full-text search with category filters |
| `list_knowledge` | List all entries; entries with rules shown first as constraints |
Expand Down
38 changes: 20 additions & 18 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ func main() {
showVersion := flag.Bool("version", false, "Print version and exit")
globalPath := flag.String("global", "", "Path to a global knowledge store shared across all projects")
indexOverride := flag.String("index", "", "Override the search index location")
storeDir := flag.String("store", "", "Central directory for all knowledge stores; in-tree .agents/ is ignored when set")
flag.Var(&roots, "root", "Org root whose immediate children are projects (repeatable)")
flag.Var(&projs, "project", "Single project root (repeatable)")
flag.Parse()
Expand All @@ -58,7 +59,7 @@ func main() {
os.Exit(1)
}

resolver, warnings, err := projects.Build([]string(roots), []string(projs), *globalPath)
resolver, warnings, err := projects.BuildWithStore([]string(roots), []string(projs), *globalPath, *storeDir)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
Expand All @@ -67,7 +68,7 @@ func main() {
log.Printf("warning: %s", w)
}

indexBasePath, err := indexLocation(*indexOverride, roots, projs, resolver.Entries())
indexBasePath, err := indexLocation(*indexOverride, *storeDir, roots, projs, resolver.Entries())
if err != nil {
log.Fatalf("determine index location: %v", err)
}
Expand Down Expand Up @@ -226,19 +227,18 @@ func indexAll(res *projects.Resolver, idx *search.Index) {
for _, ref := range res.Snapshot() {
switch ref.Kind {
case projects.KindProject:
indexProjectKnowledge(ref.Path, ref.Address, idx)
indexProjectKnowledge(res.AgentsDir(ref), ref.Address, idx)
case projects.KindOrg:
indexOrgKnowledge(ref.Path, ref.Name, idx)
indexOrgKnowledge(res.OrgKnowledgeDir(ref), ref.Name, idx)
case projects.KindGlobal:
indexProjectKnowledge(ref.Path, ref.Address, idx)
indexProjectKnowledge(res.AgentsDir(ref), ref.Address, idx)
}
}
}

// indexProjectKnowledge indexes all knowledge files in a single project
// indexProjectKnowledge indexes all knowledge files under an agents dir
// under the given addressing name.
func indexProjectKnowledge(projectPath, projectName string, idx *search.Index) {
agentsDir := filepath.Join(projectPath, ".agents")
func indexProjectKnowledge(agentsDir, projectName string, idx *search.Index) {
for _, cat := range knowledge.ValidCategories() {
catPath := knowledge.CategoryFilePath(agentsDir, cat)
kf, err := knowledge.Load(catPath)
Expand All @@ -260,13 +260,12 @@ func indexProjectKnowledge(projectPath, projectName string, idx *search.Index) {
}
}

// indexOrgKnowledge indexes an org root's .agents/knowledge/ files as
// individual markdown sections, so queries return only the relevant
// section instead of the entire document.
func indexOrgKnowledge(root, orgName string, idx *search.Index) {
orgAgentsDir := filepath.Join(root, ".agents", "knowledge")
// indexOrgKnowledge indexes an org's knowledge files as individual markdown
// sections, so queries return only the relevant section instead of the
// entire document.
func indexOrgKnowledge(knowledgeDir, orgName string, idx *search.Index) {
for _, catFile := range []string{"architecture.md", "review.md"} {
filePath := filepath.Join(orgAgentsDir, catFile)
filePath := filepath.Join(knowledgeDir, catFile)
data, err := os.ReadFile(filePath)
if err != nil {
continue
Expand Down Expand Up @@ -325,13 +324,16 @@ func splitSections(data string) [][2]string {
return sections
}

// indexLocation picks the bleve index path: explicit override, legacy
// per-config locations for single-entry configs, or a hashed XDG state
// dir for multi-entry configs.
func indexLocation(override string, roots, projs pathList, canonicalEntries []string) (string, error) {
// indexLocation picks the bleve index path: explicit override, a --store
// default (<store>/.index), legacy per-config locations for single-entry
// configs, or a hashed XDG state dir for multi-entry configs.
func indexLocation(override, store string, roots, projs pathList, canonicalEntries []string) (string, error) {
if override != "" {
return filepath.Abs(override)
}
if store != "" {
return filepath.Join(store, ".index"), nil
}
if len(roots)+len(projs) == 1 {
single := roots
if len(single) == 0 {
Expand Down
44 changes: 37 additions & 7 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func TestPathListSet(t *testing.T) {
}

func TestIndexLocationOverride(t *testing.T) {
p, err := indexLocation("/custom/index", pathList{}, pathList{}, nil)
p, err := indexLocation("/custom/index", "", pathList{}, pathList{}, nil)
if err != nil {
t.Fatalf("indexLocation() error: %v", err)
}
Expand All @@ -67,7 +67,7 @@ func TestIndexLocationOverride(t *testing.T) {
}

func TestIndexLocationLegacySingleProject(t *testing.T) {
p, err := indexLocation("", pathList{}, pathList{"/proj"}, nil)
p, err := indexLocation("", "", pathList{}, pathList{"/proj"}, nil)
if err != nil {
t.Fatalf("indexLocation() error: %v", err)
}
Expand All @@ -77,7 +77,7 @@ func TestIndexLocationLegacySingleProject(t *testing.T) {
}

func TestIndexLocationLegacySingleRoot(t *testing.T) {
p, err := indexLocation("", pathList{"/root"}, pathList{}, nil)
p, err := indexLocation("", "", pathList{"/root"}, pathList{}, nil)
if err != nil {
t.Fatalf("indexLocation() error: %v", err)
}
Expand All @@ -90,7 +90,7 @@ func TestIndexLocationMultiEntryStateDir(t *testing.T) {
t.Setenv("XDG_STATE_HOME", t.TempDir())

entries := []string{"/rootB", "/rootA", "/projC"}
p, err := indexLocation("", pathList{"/rootB", "/rootA"}, pathList{"/projC"}, entries)
p, err := indexLocation("", "", pathList{"/rootB", "/rootA"}, pathList{"/projC"}, entries)
if err != nil {
t.Fatalf("indexLocation() error: %v", err)
}
Expand All @@ -99,7 +99,7 @@ func TestIndexLocationMultiEntryStateDir(t *testing.T) {
}

// Same entries -> same location (deterministic).
p2, err := indexLocation("", pathList{"/rootA", "/rootB"}, pathList{"/projC"}, entries)
p2, err := indexLocation("", "", pathList{"/rootA", "/rootB"}, pathList{"/projC"}, entries)
if err != nil {
t.Fatalf("indexLocation() error: %v", err)
}
Expand All @@ -109,7 +109,7 @@ func TestIndexLocationMultiEntryStateDir(t *testing.T) {

// Different entries -> different location.
entries2 := []string{"/rootB", "/rootA", "/projD"}
p3, err := indexLocation("", pathList{"/rootB", "/rootA"}, pathList{"/projD"}, entries2)
p3, err := indexLocation("", "", pathList{"/rootB", "/rootA"}, pathList{"/projD"}, entries2)
if err != nil {
t.Fatalf("indexLocation() error: %v", err)
}
Expand All @@ -124,11 +124,41 @@ func TestIndexLocationXDGDefault(t *testing.T) {
if err != nil {
t.Skip("no home dir")
}
p, err := indexLocation("", pathList{"/a", "/b"}, pathList{}, []string{"/a", "/b"})
p, err := indexLocation("", "", pathList{"/a", "/b"}, pathList{}, []string{"/a", "/b"})
if err != nil {
t.Fatalf("indexLocation() error: %v", err)
}
if want := filepath.Join(home, ".local", "state", "knowledge-mcp"); !strings.HasPrefix(p, want) {
t.Errorf("location = %q, want under %q", p, want)
}
}

func TestIndexLocationStoreDefault(t *testing.T) {
p, err := indexLocation("", "/store", pathList{"/root"}, pathList{}, nil)
if err != nil {
t.Fatalf("indexLocation() error: %v", err)
}
if want := filepath.Join("/store", ".index"); p != want {
t.Errorf("location = %q, want %q", p, want)
}
}

func TestIndexLocationStoreMultiEntry(t *testing.T) {
p, err := indexLocation("", "/store", pathList{"/a", "/b"}, pathList{"/c"}, []string{"/a", "/b", "/c"})
if err != nil {
t.Fatalf("indexLocation() error: %v", err)
}
if want := filepath.Join("/store", ".index"); p != want {
t.Errorf("location = %q, want %q", p, want)
}
}

func TestIndexLocationOverrideBeatsStore(t *testing.T) {
p, err := indexLocation("/custom/index", "/store", pathList{}, pathList{}, nil)
if err != nil {
t.Fatalf("indexLocation() error: %v", err)
}
if p != "/custom/index" {
t.Errorf("location = %q, want %q", p, "/custom/index")
}
}
Loading
Loading