Skip to content
Merged
37 changes: 35 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,41 @@ An MCP server for persistent agent knowledge. Agents query and write structured

## Quick Start

One command downloads the binary for your platform and wires everything up
(OpenCode MCP entry, agent instructions block, skill, and the Grep/Glob
augment hook):

```bash
curl -fsSL https://raw.githubusercontent.com/renderorange/knowledge-mcp/main/install.sh -o install.sh
chmod +x install.sh
./install.sh --root /path/to/org --global /path/to/global-store
```

Flags passed to `install.sh` become the server's flags (`--root`,
`--project`, `--global`, `--store`, `--index`). `--version <tag>` pins a
specific release; `--dry-run` previews what would happen.

What install writes:

- the MCP server entry in `~/.config/opencode/opencode.jsonc` (marker-delimited)
- an instruction block in `~/.config/opencode/AGENTS.md`
- the `knowledge-mcp` skill at `~/.config/opencode/skills/knowledge-mcp/SKILL.md`
- the augment plugin at `~/.config/opencode/plugins/knowledge-mcp.ts`
- an install record at `~/.config/knowledge-mcp/install.json`

The plugin intercepts Grep/Glob and appends matching knowledge-store entries
to the results, so agent searches surface stored conventions automatically.

Remove the integration (data-safe — store files are never touched):

```bash
knowledge-mcp uninstall [--remove-binary]
```

The client manages the server process — you don't need to run it manually.

### Build From Source

```bash
make build
```
Expand All @@ -14,8 +49,6 @@ Or with a specific version:
make build/1.2.3
```

Then configure your MCP client (see [Agent Configuration](#agent-configuration) below). The client manages the server process — you don't need to run it manually.

Check the version:

```bash
Expand Down
65 changes: 65 additions & 0 deletions hook/hook.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package hook

import (
"encoding/json"
"fmt"
"io"
"os"
"strings"

"github.com/renderorange/knowledge-mcp/install"
"github.com/renderorange/knowledge-mcp/projects"
)

// pluginEvent is the JSON the opencode plugin sends on stdin.
type pluginEvent struct {
HookEventName string `json:"hook_event_name"`
ToolName string `json:"tool_name"`
ToolInput map[string]any `json:"tool_input"`
}

// Run executes hook-augment: resolve the cwd project from the recorded
// install config and print matching knowledge entries for the tool's
// pattern. Any failure leaves output empty — a hook must never break the
// client tool it augments.
func Run(in io.Reader, out io.Writer) error {
data, err := io.ReadAll(io.LimitReader(in, 1<<20))
if err != nil {
return err
}
var ev pluginEvent
if err := json.Unmarshal(data, &ev); err != nil {
return err
}
pattern, ok := ev.ToolInput["pattern"].(string)
if !ok || strings.TrimSpace(pattern) == "" {
return nil
}

rec, ok := install.LoadRecord()
if !ok {
return nil
}
res, _, err := projects.BuildWithStore(rec.Roots, rec.Projects, rec.Global, rec.Store)
if err != nil {
return nil
}

cwd := os.Getenv("KNM_HOOK_CWD")
if cwd == "" {
cwd, _ = os.Getwd()
}
proj, org := resolveCwd(res, cwd)

hits := Search(res, proj, org, pattern, 3)
if len(hits) == 0 {
return nil
}

fmt.Fprintf(out, "Knowledge store hits for %q:\n", pattern)
for _, h := range hits {
fmt.Fprintf(out, "- %s/%s: %s — query_knowledge(%q, project=%q) for detail\n",
h.Address, h.ID, h.Summary, pattern, h.Address)
}
return nil
}
67 changes: 67 additions & 0 deletions hook/hook_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package hook

import (
"bytes"
"path/filepath"
"strings"
"testing"

"github.com/renderorange/knowledge-mcp/install"
"github.com/renderorange/knowledge-mcp/knowledge"
)

func TestRunGolden(t *testing.T) {
root := t.TempDir()
proj := filepath.Join(root, "proj")
if err := knowledge.EnsureDir(filepath.Join(proj, ".agents")); err != nil {
t.Fatal(err)
}
kf := &knowledge.KnowledgeFile{Project: "proj", Version: 1, Entries: []knowledge.Entry{
{ID: "conv-001", Summary: "Tmp directory rules", Detail: "docs in ./tmp/docs", Source: "u", Date: knowledge.Today()},
}}
if err := knowledge.Save(knowledge.CategoryFilePath(filepath.Join(proj, ".agents"), "conventions"), kf); err != nil {
t.Fatal(err)
}

cfgRoot := t.TempDir()
t.Setenv("KNM_CONFIG_DIR", filepath.Join(cfgRoot, "knowledge-mcp"))
t.Setenv("KNM_HOOK_CWD", proj)
if err := install.SaveRecord(install.Record{Roots: []string{root}, Version: "v", BinPath: "/x"}); err != nil {
t.Fatal(err)
}

in := bytes.NewBufferString(`{"hook_event_name":"PostToolUse","tool_name":"Grep","tool_input":{"pattern":"tmp directory"}}`)
var out bytes.Buffer
if err := Run(in, &out); err != nil {
t.Fatalf("Run() error: %v", err)
}
s := out.String()
if !strings.Contains(s, "conv-001") || !strings.Contains(s, "query_knowledge") {
t.Fatalf("output missing hit:\n%s", s)
}
}

func TestRunNoRecordSilent(t *testing.T) {
t.Setenv("KNM_CONFIG_DIR", filepath.Join(t.TempDir(), "knowledge-mcp"))
t.Setenv("KNM_HOOK_CWD", t.TempDir())
in := bytes.NewBufferString(`{"hook_event_name":"PostToolUse","tool_name":"Grep","tool_input":{"pattern":"x"}}`)
var out bytes.Buffer
if err := Run(in, &out); err != nil {
t.Fatalf("Run() error: %v", err)
}
if out.Len() != 0 {
t.Fatalf("expected empty output, got:\n%s", out.String())
}
}

func TestRunNoPatternSilent(t *testing.T) {
t.Setenv("KNM_CONFIG_DIR", filepath.Join(t.TempDir(), "knowledge-mcp"))
in := bytes.NewBufferString(`{"hook_event_name":"PostToolUse","tool_name":"Grep","tool_input":{}}`)
var out bytes.Buffer
if err := Run(in, &out); err != nil {
t.Fatalf("Run() error: %v", err)
}
if out.Len() != 0 {
t.Fatalf("expected empty output, got %q", out.String())
}
}
179 changes: 179 additions & 0 deletions hook/match_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
package hook

import (
"os"
"path/filepath"
"testing"

"github.com/renderorange/knowledge-mcp/knowledge"
"github.com/renderorange/knowledge-mcp/projects"
)

func seedEntry(t *testing.T, dir, cat string, entries ...knowledge.Entry) {
t.Helper()
if err := knowledge.EnsureDir(dir); err != nil {
t.Fatal(err)
}
kf := &knowledge.KnowledgeFile{Project: "p", Version: 1, Entries: entries}
if err := knowledge.Save(knowledge.CategoryFilePath(dir, cat), kf); err != nil {
t.Fatal(err)
}
}

func lookupRef(t *testing.T, res *projects.Resolver, path string) *projects.Ref {
t.Helper()
ref, ok := res.RefForPath(path)
if !ok {
t.Fatalf("RefForPath(%s) not ok", path)
}
return &ref
}

func TestSearchProjectHits(t *testing.T) {
root := t.TempDir()
proj := filepath.Join(root, "proj")
seedEntry(t, filepath.Join(proj, ".agents"), "conventions",
knowledge.Entry{ID: "conv-001", Summary: "Tmp directory rules", Detail: "All project docs go in ./tmp/docs/ only", Source: "user", Date: knowledge.Today()},
knowledge.Entry{ID: "conv-002", Summary: "Unrelated", Detail: "nothing here", Source: "user", Date: knowledge.Today()},
)
res, _, err := projects.Build([]string{root}, nil, "")
if err != nil {
t.Fatal(err)
}
hits := Search(res, lookupRef(t, res, proj), nil, "tmp directory", 3)
if len(hits) != 1 {
t.Fatalf("len(hits) = %d, want 1 (%+v)", len(hits), hits)
}
if hits[0].ID != "conv-001" || hits[0].Address != "proj" {
t.Errorf("hit = %+v", hits[0])
}
}

func TestSearchWholePatternSubstring(t *testing.T) {
root := t.TempDir()
proj := filepath.Join(root, "proj")
seedEntry(t, filepath.Join(proj, ".agents"), "decisions",
knowledge.Entry{ID: "dec-001", Summary: "Session compression", Detail: "Use sessioncompress for compaction", Source: "test", Date: knowledge.Today()},
)
res, _, err := projects.Build([]string{root}, nil, "")
if err != nil {
t.Fatal(err)
}
hits := Search(res, lookupRef(t, res, proj), nil, "sessioncompress", 3)
if len(hits) != 1 || hits[0].ID != "dec-001" {
t.Fatalf("hits = %+v", hits)
}
}

func TestSearchIncludesOrgAndGlobal(t *testing.T) {
root := t.TempDir()
proj := filepath.Join(root, "proj")
seedEntry(t, filepath.Join(proj, ".agents"), "conventions",
knowledge.Entry{ID: "conv-001", Summary: "Project one", Detail: "compression project", Source: "t", Date: knowledge.Today()},
)
orgKnowledge := filepath.Join(root, ".agents", "knowledge")
if err := os.MkdirAll(orgKnowledge, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(orgKnowledge, "architecture.md"),
[]byte("## Compression\norg-level compression notes\n"), 0644); err != nil {
t.Fatal(err)
}
globalDir := filepath.Join(t.TempDir(), "global")
seedEntry(t, filepath.Join(globalDir, ".agents"), "conventions",
knowledge.Entry{ID: "conv-009", Summary: "Global compression rule", Detail: "handle compression globally", Source: "t", Date: knowledge.Today()},
)
res, _, err := projects.Build([]string{root}, nil, globalDir)
if err != nil {
t.Fatal(err)
}
hits := Search(res, lookupRef(t, res, proj), orgRef(t, res), "compression", 5)
var addresses []string
for _, h := range hits {
addresses = append(addresses, h.Address)
}
found := func(addr string) bool {
for _, a := range addresses {
if a == addr {
return true
}
}
return false
}
if !found("proj") || !found("_global") {
t.Errorf("hits missing sources: %+v", hits)
}
}

func orgRef(t *testing.T, res *projects.Resolver) *projects.Ref {
t.Helper()
for _, ref := range res.Snapshot() {
if ref.Kind == projects.KindOrg {
return &ref
}
}
t.Fatal("no org ref")
return nil
}

func TestSearchRankingAndLimit(t *testing.T) {
root := t.TempDir()
proj := filepath.Join(root, "proj")
seedEntry(t, filepath.Join(proj, ".agents"), "conventions",
knowledge.Entry{ID: "conv-001", Summary: "Alpha", Detail: "zilch zilch zilch", Source: "t", Date: knowledge.Today()},
knowledge.Entry{ID: "conv-002", Summary: "Beta", Detail: "zilch zilch", Source: "t", Date: knowledge.Today()},
knowledge.Entry{ID: "conv-003", Summary: "Gamma", Detail: "zilch", Source: "t", Date: knowledge.Today()},
knowledge.Entry{ID: "conv-004", Summary: "Delta", Detail: "unrelated", Source: "t", Date: knowledge.Today()},
)
res, _, err := projects.Build([]string{root}, nil, "")
if err != nil {
t.Fatal(err)
}
hits := Search(res, lookupRef(t, res, proj), nil, "zilch", 2)
if len(hits) != 2 {
t.Fatalf("len(hits) = %d, want 2", len(hits))
}
if hits[0].ID != "conv-001" || hits[1].ID != "conv-002" {
t.Errorf("ranking wrong: %+v", hits)
}
}

func TestResolveCwdMatrix(t *testing.T) {
root := t.TempDir()
proj := filepath.Join(root, "proj")
sub := filepath.Join(proj, "deep", "dir")
other := t.TempDir()
globalDir := filepath.Join(t.TempDir(), "global")
for _, d := range []string{sub, other, filepath.Join(root, ".agents", "knowledge"), filepath.Join(globalDir, ".agents")} {
if err := os.MkdirAll(d, 0755); err != nil {
t.Fatal(err)
}
}
res, _, err := projects.Build([]string{root}, nil, globalDir)
if err != nil {
t.Fatal(err)
}

pRef, oRef := resolveCwd(res, proj)
if pRef == nil || pRef.Address != "proj" {
t.Errorf("exact project: got %+v", pRef)
}
if oRef == nil || oRef.Kind != projects.KindOrg {
t.Errorf("exact project: expected containing org ref, got %+v", oRef)
}

pRef, oRef = resolveCwd(res, sub)
if pRef == nil || pRef.Address != "proj" {
t.Errorf("nested under project: got %+v", pRef)
}

pRef, _ = resolveCwd(res, other)
if pRef != nil {
t.Errorf("outside roots: got %+v", pRef)
}

pRef, oRef = resolveCwd(res, root)
if pRef != nil || oRef == nil || oRef.Kind != projects.KindOrg {
t.Errorf("org root cwd: got pRef=%+v oRef=%+v", pRef, oRef)
}
}
Loading
Loading