Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,34 @@ inode config set db.dsn "postgres://postgres:password@localhost:5432/postgres?ss

inode will `CREATE EXTENSION vector` and create the `notes` table on first run. SQLite remains the default — Postgres is opt-in.

### Optional: MCP server (Claude Code / Cursor)

`inode mcp` runs as a Model Context Protocol server over stdio so an MCP-aware AI client can read your knowledge base.

Exposes three read-only tools to the calling agent:

- `search_notes` — vector search; returns the most relevant notes
- `list_notes` — paginated listing; metadata only
- `get_note` — fetch a single note by ID prefix

Sensitive notes are excluded from search results and masked in `get_note` responses by default. To let the agent see them:

```bash
inode config set mcp.reveal_sensitive true
```

Wire it into your MCP client config (Claude Code example):

```json
{
"mcpServers": {
"inode": { "command": "inode", "args": ["mcp"] }
}
}
```

Writing notes from an agent isn't exposed yet — read-only is the safer first step.

---

## Commands
Expand Down
50 changes: 50 additions & 0 deletions cmd/mcp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package cmd

import (
"github.com/shahid-io/inode/internal/mcp"
"github.com/shahid-io/inode/internal/version"
"github.com/spf13/cobra"
)

// mcpCmd runs inode as a Model Context Protocol server over stdio.
// Intended to be launched by an MCP-aware client (Claude Code, Cursor,
// etc.) — not interactively. The client speaks JSON-RPC over stdio.
//
// Example MCP client config:
//
// {
// "mcpServers": {
// "inode": { "command": "inode", "args": ["mcp"] }
// }
// }
var mcpCmd = &cobra.Command{
Use: "mcp",
Short: "Run as an MCP server over stdio (for Claude Code / Cursor)",
Long: `Run inode as a Model Context Protocol server over stdio.

Exposes three read-only tools to the calling agent:
search_notes — vector search; returns the most relevant notes
list_notes — paginated listing; metadata only
get_note — fetch a single note by ID prefix

Sensitive notes are excluded from search results and masked in get_note
responses by default. To allow the agent to see sensitive content:

inode config set mcp.reveal_sensitive true

This command is meant to be wired into an MCP client config, not run
interactively.`,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := getContainer()
if err != nil {
return err
}
h := &mcp.Handler{
Container: c,
RevealSensitive: cfg.MCP.RevealSensitive,
}
s := mcp.NewServer(version.Version, h)
return mcp.Serve(s)
},
}
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ func init() {
rootCmd.AddCommand(listCmd)
rootCmd.AddCommand(noteCmd)
rootCmd.AddCommand(configCmd)
rootCmd.AddCommand(mcpCmd)
}

func initConfig() {
Expand Down
6 changes: 5 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/shahid-io/inode

go 1.25.0
go 1.25.5

require (
github.com/anthropics/anthropic-sdk-go v1.41.0
Expand All @@ -9,6 +9,7 @@ require (
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.2
github.com/joho/godotenv v1.5.1
github.com/mark3labs/mcp-go v0.54.0
github.com/mattn/go-isatty v0.0.20
github.com/mattn/go-sqlite3 v1.14.44
github.com/pgvector/pgvector-go v0.3.0
Expand All @@ -25,6 +26,7 @@ require (
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
Expand All @@ -34,6 +36,7 @@ require (
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
Expand All @@ -46,6 +49,7 @@ require (
github.com/tidwall/sjson v1.2.5 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
Expand Down
14 changes: 12 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
Expand All @@ -32,6 +34,8 @@ github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
Expand Down Expand Up @@ -63,6 +67,8 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mark3labs/mcp-go v0.54.0 h1:PZhQvd+5xrT43cUoiaKn/hDcvLUhcLc1twSEKYPTcTA=
github.com/mark3labs/mcp-go v0.54.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
Expand All @@ -75,11 +81,13 @@ github.com/pgvector/pgvector-go v0.3.0 h1:Ij+Yt78R//uYqs3Zk35evZFvr+G0blW0OUN+Q2
github.com/pgvector/pgvector-go v0.3.0/go.mod h1:duFy+PXWfW7QQd5ibqutBO4GxLsUZ9RVXhFZGIBsWSA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
Expand Down Expand Up @@ -134,6 +142,8 @@ github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
Expand Down
13 changes: 13 additions & 0 deletions internal/config/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,20 @@ type Config struct {
DB DBConfig `mapstructure:"db"`
Defaults DefaultsConfig `mapstructure:"defaults"`
Search SearchConfig `mapstructure:"search"`
MCP MCPConfig `mapstructure:"mcp"`
Log LogConfig `mapstructure:"log"`
}

// MCPConfig tunes the `inode mcp` Model Context Protocol server.
type MCPConfig struct {
// RevealSensitive, when true, lets the MCP server include plaintext
// content of notes marked sensitive (search_notes will surface them
// to the LLM, get_note will return unmasked content). Default false:
// sensitive notes are excluded from search candidates and masked in
// get_note responses.
RevealSensitive bool `mapstructure:"reveal_sensitive"`
}

// SearchConfig tunes the RAG retrieval pipeline.
type SearchConfig struct {
// MaxDistance drops notes whose L2 distance exceeds this value (0 = use built-in default, <0 = disable).
Expand Down Expand Up @@ -84,6 +95,7 @@ func Load() (*Config, error) {
_ = v.BindEnv("embedding.dimension", "INODE_EMBEDDING_DIMENSION")
_ = v.BindEnv("db.backend", "INODE_DB_BACKEND")
_ = v.BindEnv("db.dsn", "INODE_DB_DSN")
_ = v.BindEnv("mcp.reveal_sensitive", "INODE_MCP_REVEAL_SENSITIVE")
_ = v.BindEnv("log.level", "INODE_LOG_LEVEL")

// Config file: ~/.inode/config.toml
Expand Down Expand Up @@ -111,6 +123,7 @@ func Load() (*Config, error) {
v.SetDefault("defaults.sensitive", true)
v.SetDefault("search.max_distance", 1.0)
v.SetDefault("search.top_k", 5)
v.SetDefault("mcp.reveal_sensitive", false)
v.SetDefault("log.level", "info")

var cfg Config
Expand Down
52 changes: 37 additions & 15 deletions internal/core/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ type SearchOptions struct {
Category string
Tags []string

// IsSensitive optionally restricts results by the sensitive flag.
// nil = no filter (default); pointer to false = exclude sensitive
// notes from results before they reach the LLM. Used by the MCP
// server to prevent sensitive notes leaking to the calling agent.
IsSensitive *bool

// MaxDistance is the L2-distance ceiling for keeping a candidate note.
//
// = 0 → use the service default (typically 1.0)
Expand All @@ -91,9 +97,13 @@ type SearchOptions struct {
OnStep func(step string)
}

// Search embeds the query, retrieves top-K notes, decrypts them,
// and returns an LLM-generated answer alongside the source notes.
func (s *SearchService) Search(ctx context.Context, query string, opts SearchOptions) (*SearchResult, error) {
// Retrieve runs the embed → vector search → threshold filter → decrypt
// pipeline and returns the candidate notes. No LLM call.
//
// Used by the MCP tool surface: the calling agent (Claude Code, Cursor)
// is itself an LLM and prefers to reason over raw candidates rather than
// a pre-generated answer from inode's local model.
func (s *SearchService) Retrieve(ctx context.Context, query string, opts SearchOptions) ([]*model.Note, error) {
if query == "" {
return nil, fmt.Errorf("query cannot be empty")
}
Expand All @@ -108,47 +118,59 @@ func (s *SearchService) Search(ctx context.Context, query string, opts SearchOpt
step = func(string) {}
}

// Step 1: embed the query.
step("embedding query")
vec, err := s.embedding.Embed(ctx, query)
if err != nil {
return nil, fmt.Errorf("embed query: %w", err)
}

// Step 2: vector similarity search.
step("searching")
filters := db.Filters{
Category: opts.Category,
Tags: opts.Tags,
Category: opts.Category,
Tags: opts.Tags,
IsSensitive: opts.IsSensitive,
}
notes, err := s.db.SearchSimilar(ctx, vec, topK, filters)
if err != nil {
return nil, fmt.Errorf("vector search: %w", err)
}

// Step 2b: drop notes beyond the relevance threshold so weak matches
// never reach the LLM (which would otherwise answer "no match" while
// the CLI still printed them as Sources).
notes = filterByDistance(notes, s.thresholdFor(opts))

if len(notes) == 0 {
return &SearchResult{Answer: "No relevant notes found.", Notes: nil}, nil
return nil, nil
}

// Step 3: decrypt sensitive notes in memory.
decrypted, err := s.decryptNotes(notes)
if err != nil {
return nil, fmt.Errorf("decrypt notes: %w", err)
}
return decrypted, nil
}

// Search embeds the query, retrieves top-K notes, decrypts them,
// and returns an LLM-generated answer alongside the source notes.
func (s *SearchService) Search(ctx context.Context, query string, opts SearchOptions) (*SearchResult, error) {
decrypted, err := s.Retrieve(ctx, query, opts)
if err != nil {
return nil, err
}
if len(decrypted) == 0 {
return &SearchResult{Answer: "No relevant notes found.", Notes: nil}, nil
}

step := opts.OnStep
if step == nil {
step = func(string) {}
}

// Step 4: LLM generates an answer and tells us which notes it actually used.
// LLM generates an answer and tells us which notes it actually used.
step("thinking")
answer, err := s.llm.Answer(ctx, query, decrypted)
if err != nil {
return nil, fmt.Errorf("llm answer: %w", err)
}

// Step 5: filter the source list down to notes the LLM said it relied on.
// Filter the source list down to notes the LLM said it relied on.
// When the LLM rejected every candidate (Matched=false), we hide the
// "Sources" list entirely — the answer alone is shown.
sources := filterByMatchedIDs(decrypted, answer.UsedNoteIDs)
Expand Down
Loading
Loading