diff --git a/README.md b/README.md index 7cff4c4..25c1cf4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/mcp.go b/cmd/mcp.go new file mode 100644 index 0000000..97b14be --- /dev/null +++ b/cmd/mcp.go @@ -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) + }, +} diff --git a/cmd/root.go b/cmd/root.go index 5c84630..83cd411 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -49,6 +49,7 @@ func init() { rootCmd.AddCommand(listCmd) rootCmd.AddCommand(noteCmd) rootCmd.AddCommand(configCmd) + rootCmd.AddCommand(mcpCmd) } func initConfig() { diff --git a/go.mod b/go.mod index e5a7f10..ccfec51 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/go.sum b/go.sum index b5778e7..a19540a 100644 --- a/go.sum +++ b/go.sum @@ -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= @@ -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= @@ -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= @@ -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= @@ -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= diff --git a/internal/config/settings.go b/internal/config/settings.go index 1b6eac0..862fb05 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -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). @@ -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 @@ -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 diff --git a/internal/core/search.go b/internal/core/search.go index 7d4b663..399ce7e 100644 --- a/internal/core/search.go +++ b/internal/core/search.go @@ -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) @@ -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") } @@ -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) diff --git a/internal/mcp/server.go b/internal/mcp/server.go new file mode 100644 index 0000000..3be5b0a --- /dev/null +++ b/internal/mcp/server.go @@ -0,0 +1,93 @@ +// Package mcp exposes the inode knowledge base over the Model Context +// Protocol so MCP-aware AI clients (Claude Code, Cursor, etc.) can read +// notes via stdio. +// +// v1 is read-only by design: search_notes, list_notes, get_note. Writing +// notes from an agent would let it fill — or wipe — the user's KB without +// the user's intent showing up anywhere reviewable, so it stays opt-in +// for a future iteration. +// +// Sensitive notes are never revealed to the agent unless mcp.reveal_sensitive +// is explicitly set in config. The default (false) excludes them from +// search candidates entirely (so the LLM cannot quote secrets in answers) +// and masks their content in get_note responses. +package mcp + +import ( + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/shahid-io/inode/internal/core" +) + +// Handler owns the inode services the MCP tools delegate to. +type Handler struct { + Container *core.Container + RevealSensitive bool +} + +// NewServer builds an MCP server with the inode tool surface registered. +// Caller is responsible for actually serving it (e.g. server.ServeStdio). +func NewServer(version string, h *Handler) *server.MCPServer { + s := server.NewMCPServer( + "inode", + version, + server.WithToolCapabilities(true), + ) + + s.AddTool( + mcp.NewTool("search_notes", + mcp.WithDescription( + "Search the user's inode knowledge base by natural-language query. "+ + "Returns an LLM-generated answer grounded in the most relevant notes, "+ + "with the source notes listed below. Use this for questions like "+ + "'what's the docker cleanup command' or 'how did we configure auth in service X'.", + ), + mcp.WithString("query", mcp.Required(), + mcp.Description("Natural-language search query")), + mcp.WithNumber("top_k", + mcp.Description("Max number of source notes to consider (default 5)")), + mcp.WithString("category", + mcp.Description("Filter to one category: credentials, commands, snippets, decisions, runbooks, learnings, references, contacts, notes")), + ), + h.handleSearchNotes, + ) + + s.AddTool( + mcp.NewTool("list_notes", + mcp.WithDescription( + "List notes from the user's inode knowledge base, optionally filtered "+ + "by category or tag. Returns metadata only (id, summary, category, "+ + "tags, sensitivity) — not note content. Use this to browse what's "+ + "stored before deciding what to look up in detail.", + ), + mcp.WithString("category", + mcp.Description("Filter by category (see search_notes for the list)")), + mcp.WithString("tag", + mcp.Description("Filter by a single tag")), + mcp.WithNumber("limit", + mcp.Description("Max notes to return (default 20)")), + ), + h.handleListNotes, + ) + + s.AddTool( + mcp.NewTool("get_note", + mcp.WithDescription( + "Fetch a single note by ID. The ID may be the full UUID or a short "+ + "prefix (8+ characters is typical and unambiguous). Returns the "+ + "note's content along with its metadata. Sensitive notes are "+ + "returned with masked content unless mcp.reveal_sensitive is set.", + ), + mcp.WithString("id", mcp.Required(), + mcp.Description("Note ID (full UUID or short prefix)")), + ), + h.handleGetNote, + ) + + return s +} + +// Serve runs the server over stdio. Blocks until the client disconnects. +func Serve(s *server.MCPServer) error { + return server.ServeStdio(s) +} diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go new file mode 100644 index 0000000..b133a76 --- /dev/null +++ b/internal/mcp/tools.go @@ -0,0 +1,158 @@ +package mcp + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/shahid-io/inode/internal/adapters/db" + "github.com/shahid-io/inode/internal/core" + "github.com/shahid-io/inode/internal/model" +) + +// maskedContent is what get_note returns in place of a sensitive note's +// plaintext when reveal_sensitive is off. Six bullets matches the CLI +// MaskSensitive default for short values. +const maskedContent = "••••••" + +func (h *Handler) handleSearchNotes(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + query, err := req.RequireString("query") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + opts := core.SearchOptions{ + TopK: int(req.GetFloat("top_k", 0)), + Category: req.GetString("category", ""), + } + if !h.RevealSensitive { + // Sensitive notes never reach the calling agent — not in + // the candidate set, not in the formatted result. + notSensitive := false + opts.IsSensitive = ¬Sensitive + } + + notes, err := h.Container.Search.Retrieve(ctx, query, opts) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if len(notes) == 0 { + return mcp.NewToolResultText("No relevant notes found."), nil + } + + return mcp.NewToolResultText(formatSearchResults(query, notes, h.RevealSensitive)), nil +} + +func (h *Handler) handleListNotes(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + limit := int(req.GetFloat("limit", 20)) + filters := db.Filters{ + Category: req.GetString("category", ""), + } + if tag := req.GetString("tag", ""); tag != "" { + filters.Tags = []string{tag} + } + + notes, err := h.Container.Notes.List(ctx, filters, limit, 0) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if len(notes) == 0 { + return mcp.NewToolResultText("No notes match the given filters."), nil + } + + return mcp.NewToolResultText(formatNoteList(notes)), nil +} + +func (h *Handler) handleGetNote(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + id, err := req.RequireString("id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + note, err := h.Container.Notes.Get(ctx, id) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("note %q not found", id)), nil + } + + return mcp.NewToolResultText(formatNote(note, h.RevealSensitive)), nil +} + +// formatSearchResults renders retrieved notes for an MCP agent. The +// agent (Claude, Cursor's model) is itself an LLM — it can read prose +// well, so a lightly-structured block with header + per-note sections +// works better than CSV or JSON. +func formatSearchResults(query string, notes []*model.Note, reveal bool) string { + var sb strings.Builder + fmt.Fprintf(&sb, "Found %d relevant note(s) for: %s\n\n", len(notes), query) + for i, n := range notes { + fmt.Fprintf(&sb, "%d. [#%s] %s\n", i+1, shortID(n.ID), n.Summary) + fmt.Fprintf(&sb, " category: %s", n.Category) + if len(n.Tags) > 0 { + fmt.Fprintf(&sb, " | tags: %s", strings.Join(n.Tags, ", ")) + } + if n.Distance > 0 { + fmt.Fprintf(&sb, " | distance: %.3f", n.Distance) + } + fmt.Fprintln(&sb) + fmt.Fprintf(&sb, " content: %s\n\n", contentFor(n, reveal)) + } + return strings.TrimRight(sb.String(), "\n") +} + +func formatNoteList(notes []*model.Note) string { + var sb strings.Builder + fmt.Fprintf(&sb, "%d note(s):\n\n", len(notes)) + for _, n := range notes { + sensitive := "" + if n.IsSensitive { + sensitive = " [sensitive]" + } + fmt.Fprintf(&sb, "[#%s] %s — %s", shortID(n.ID), n.Category, n.Summary) + if len(n.Tags) > 0 { + fmt.Fprintf(&sb, " (tags: %s)", strings.Join(n.Tags, ", ")) + } + fmt.Fprintf(&sb, "%s\n", sensitive) + } + return strings.TrimRight(sb.String(), "\n") +} + +func formatNote(n *model.Note, reveal bool) string { + var sb strings.Builder + fmt.Fprintf(&sb, "ID: %s\n", n.ID) + fmt.Fprintf(&sb, "Summary: %s\n", n.Summary) + fmt.Fprintf(&sb, "Category: %s\n", n.Category) + if len(n.Tags) > 0 { + fmt.Fprintf(&sb, "Tags: %s\n", strings.Join(n.Tags, ", ")) + } + fmt.Fprintf(&sb, "Sensitive: %t\n", n.IsSensitive) + if !n.CreatedAt.IsZero() { + fmt.Fprintf(&sb, "Created: %s\n", n.CreatedAt.Format(time.RFC3339)) + } + fmt.Fprintf(&sb, "\nContent:\n%s\n", contentFor(n, reveal)) + return strings.TrimRight(sb.String(), "\n") +} + +func contentFor(n *model.Note, reveal bool) string { + if n.IsSensitive && !reveal { + return maskedContent + } + if n.ContentPlain != "" { + return n.ContentPlain + } + // Sensitive notes have plaintext only after the service decrypts them. + // If we ended up here for a sensitive note with no plaintext, it means + // decryption was skipped — fall back to mask rather than leak metadata. + if n.IsSensitive { + return maskedContent + } + return "" +} + +func shortID(id string) string { + if len(id) <= 8 { + return id + } + return id[:8] +} diff --git a/internal/mcp/tools_test.go b/internal/mcp/tools_test.go new file mode 100644 index 0000000..347f212 --- /dev/null +++ b/internal/mcp/tools_test.go @@ -0,0 +1,159 @@ +package mcp + +import ( + "strings" + "testing" + "time" + + "github.com/shahid-io/inode/internal/model" +) + +func TestContentFor_NonSensitive_ReturnsPlaintext(t *testing.T) { + n := &model.Note{ContentPlain: "echo hello", IsSensitive: false} + if got := contentFor(n, false); got != "echo hello" { + t.Errorf("non-sensitive note should pass through plaintext, got %q", got) + } +} + +func TestContentFor_Sensitive_NoReveal_Masks(t *testing.T) { + n := &model.Note{ContentPlain: "sk-ant-secret", IsSensitive: true} + if got := contentFor(n, false); got != maskedContent { + t.Errorf("sensitive + !reveal should mask, got %q", got) + } +} + +func TestContentFor_Sensitive_WithReveal_Returns(t *testing.T) { + n := &model.Note{ContentPlain: "sk-ant-secret", IsSensitive: true} + if got := contentFor(n, true); got != "sk-ant-secret" { + t.Errorf("sensitive + reveal should return plaintext, got %q", got) + } +} + +func TestContentFor_Sensitive_NoPlaintext_DefensivelyMasks(t *testing.T) { + // If something upstream skipped decryption, the plaintext field is empty. + // We must not leak the empty string (which the LLM would interpret as + // "no content") — masking is the safer default than ambiguity. + n := &model.Note{ContentPlain: "", IsSensitive: true} + if got := contentFor(n, true); got != maskedContent { + t.Errorf("sensitive note with no plaintext should mask even with reveal=true, got %q", got) + } +} + +func TestFormatSearchResults_IncludesAllMetadata(t *testing.T) { + notes := []*model.Note{ + { + ID: "dc9a928b0000000000000000", + Summary: "echo hello command", + Category: "commands", + Tags: []string{"bash", "demo"}, + ContentPlain: "echo hello", + Distance: 0.123, + }, + } + out := formatSearchResults("hello", notes, false) + for _, want := range []string{"dc9a928b", "echo hello command", "commands", "bash, demo", "0.123", "echo hello"} { + if !strings.Contains(out, want) { + t.Errorf("missing %q in formatted output:\n%s", want, out) + } + } +} + +func TestFormatSearchResults_SensitiveMaskedWhenNoReveal(t *testing.T) { + notes := []*model.Note{ + { + ID: "ab123456" + strings.Repeat("0", 24), + Summary: "stripe key", + Category: "credentials", + ContentPlain: "sk_live_secret", + IsSensitive: true, + }, + } + out := formatSearchResults("stripe", notes, false) + if strings.Contains(out, "sk_live_secret") { + t.Errorf("plaintext leaked when reveal=false:\n%s", out) + } + if !strings.Contains(out, maskedContent) { + t.Errorf("expected masked content marker in output:\n%s", out) + } + // Metadata should still flow through. + if !strings.Contains(out, "stripe key") || !strings.Contains(out, "credentials") { + t.Errorf("metadata stripped from masked note:\n%s", out) + } +} + +func TestFormatSearchResults_SensitiveRevealedWithReveal(t *testing.T) { + notes := []*model.Note{ + { + ID: "ab123456" + strings.Repeat("0", 24), + Summary: "stripe key", + Category: "credentials", + ContentPlain: "sk_live_secret", + IsSensitive: true, + }, + } + out := formatSearchResults("stripe", notes, true) + if !strings.Contains(out, "sk_live_secret") { + t.Errorf("plaintext missing when reveal=true:\n%s", out) + } +} + +func TestFormatNoteList_FlagsSensitive(t *testing.T) { + notes := []*model.Note{ + {ID: "aaaaaaaa00000000", Summary: "regular note", Category: "notes"}, + {ID: "bbbbbbbb00000000", Summary: "stripe key", Category: "credentials", IsSensitive: true}, + } + out := formatNoteList(notes) + if !strings.Contains(out, "[sensitive]") { + t.Errorf("sensitive marker missing from list output:\n%s", out) + } + // Non-sensitive note should NOT carry the marker on its line. + lines := strings.Split(out, "\n") + for _, line := range lines { + if strings.Contains(line, "regular note") && strings.Contains(line, "[sensitive]") { + t.Errorf("non-sensitive note wrongly flagged sensitive: %q", line) + } + } +} + +func TestFormatNote_HeaderFieldsPresent(t *testing.T) { + n := &model.Note{ + ID: "abcd1234efgh5678", + Summary: "smoke test", + Category: "notes", + Tags: []string{"smoke"}, + ContentPlain: "hello world", + CreatedAt: time.Date(2026, 5, 13, 15, 30, 0, 0, time.UTC), + } + out := formatNote(n, false) + for _, want := range []string{"ID:", "abcd1234efgh5678", "Summary:", "smoke test", "Category:", "Tags:", "Created:", "Content:", "hello world"} { + if !strings.Contains(out, want) { + t.Errorf("missing %q in formatted note:\n%s", want, out) + } + } +} + +func TestFormatNote_SensitiveMaskedWhenNoReveal(t *testing.T) { + n := &model.Note{ + ID: "secret00", + Summary: "aws key", + Category: "credentials", + ContentPlain: "AKIA-EXAMPLE-KEY", + IsSensitive: true, + } + out := formatNote(n, false) + if strings.Contains(out, "AKIA-EXAMPLE-KEY") { + t.Errorf("plaintext leaked from get_note response when reveal=false:\n%s", out) + } + if !strings.Contains(out, maskedContent) { + t.Errorf("masked content marker missing:\n%s", out) + } +} + +func TestShortID(t *testing.T) { + if got := shortID("abcd1234ef56"); got != "abcd1234" { + t.Errorf("expected 8-char prefix, got %q", got) + } + if got := shortID("short"); got != "short" { + t.Errorf("ID shorter than 8 chars should pass through, got %q", got) + } +}