Files
ely-eye/ely-eye-mcp/internal/mcpserver/directory_tools.go
T
ZacharyZhang-NY dfc6cd4c28 Give the system a directory: ask who knows, then ask them
A memory system that only answers from its own store is a filing cabinet,
not a partner. The transactive rule the project is named for — not knowing
is fine when you know who does — needed a holder registry, a directory
query, and a referral that never leaks the text it may not read.

- schema v5 adds `holders` (person/agent/service, declared topics, contact).
  A holder with memories exists whether or not anybody registered them; the
  registry adds reachability, it does not grant existence.
- `who_knows` ranks holders by their own memories and is deliberately NOT
  filtered by visibility: "she knows" is the answer. The answer is filtered.
- `ask` searches the asker's visible scope first (hop 0), then walks the
  directory with a visited set and a hop budget. Three terminal states:
  answered, referred, no_holder_knows. Nobody knowing is a result, not an error.
- Confidence is ordinal, not calibrated: relative strength, coverage, and
  declared topics. Every hit carries the raw numbers and the basis lines that
  produced the score, so a ranking can be argued with.
- Faces: HTTP (/api/holders, /api/who_knows, /api/ask), CLI (holders,
  holder-set, holder-rm, who-knows, ask), MCP (who_knows, ask, list_holders).

Four bugs found by running the thing, not by reading it:
- FTS5 refuses bm25() inside MAX(); the aggregate uses FTS5's own `rank` column.
- A CJK question is longer than the sentence that answers it, so the whole-run
  phrase matched nothing. The query now ORs adjacent bigram phrases; adjacency
  is kept, equal length is not. Latin path untouched, baseline unaffected.
- The hop budget counted the asker as a hop, so max_hops=1 asked nobody.
- A second, stricter readability test in _answered() disagreed with the SQL
  visibility clause and hid a memory the asker was allowed to read. The SQL
  clause is the only authority now.

A method named `list` shadowed the builtin inside its own class and made
mypy reject the return annotation; renamed rather than cast around it.

Proof: 155 pytest, 12 Go tests, ruff and mypy clean on touched files, pyright
ledger unchanged at 62, and scripts/mcp_write_smoke.sh drives two principals
on one SQLite file — the husband asks through MCP, gets `referred` with his
wife's contact channel, and her private sentence appears nowhere in the reply.
2026-10-07 17:20:45 -04:00

103 lines
3.8 KiB
Go

package mcpserver
import (
"context"
"errors"
"github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/domain"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
type WhoKnowsInput struct {
Query string `json:"query" jsonschema:"what the asker wants to know, in the asker's own words"`
AsHolder string `json:"as_holder,omitempty" jsonschema:"who is asking; decides which memories may be shown"`
Limit int `json:"limit,omitempty" jsonschema:"how many candidates to return, default 8"`
}
type AskInput struct {
Question string `json:"question" jsonschema:"the question to answer from memory"`
AsHolder string `json:"as_holder,omitempty" jsonschema:"who is asking; their own memory is checked first"`
MaxHops int `json:"max_hops,omitempty" jsonschema:"how many holders besides the asker may be asked, default 3"`
IncludeText bool `json:"include_text,omitempty" jsonschema:"show the memory text when the asker may already read it"`
}
// registerDirectoryTools exposes the transactive surface: who knows, and ask-and-get-referred.
func (s *Server) registerDirectoryTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{
Title: "Who knows",
Name: "who_knows",
Description: "Rank the holders whose memories answer a query, with the evidence and how to reach them. The directory counts memories the asker cannot read; that is the point. no_holder_knows is a result, not a failure.",
}, s.whoKnows)
mcp.AddTool(server, &mcp.Tool{
Title: "Ask and get referred",
Name: "ask",
Description: "Ask the asker's own memory, then the directory. answered means somebody's memory matched; referred means the best holder's memory is private, so the answer is the pointer and contact, never the text.",
}, s.askMemory)
mcp.AddTool(server, &mcp.Tool{
Title: "List holders",
Name: "list_holders",
Description: "List registered holders: the people, agents, and services a referral can walk to.",
}, s.listHolders)
}
type whoKnowsOutput struct {
Report domain.WhoKnowsReport `json:"report" jsonschema:"the ranked candidates and their evidence"`
}
type askOutput struct {
Answer domain.ReferralAnswer `json:"answer" jsonschema:"who answered, who was asked, and what may be shown"`
}
type holdersOutput struct {
Holders []domain.Holder `json:"holders" jsonschema:"the registered holders"`
}
func (s *Server) whoKnows(ctx context.Context, _ *mcp.CallToolRequest, in WhoKnowsInput) (*mcp.CallToolResult, whoKnowsOutput, error) {
if in.Query == "" {
return nil, whoKnowsOutput{}, errors.New("query is required")
}
var report domain.WhoKnowsReport
body := map[string]any{"query": in.Query}
if in.AsHolder != "" {
body["as_holder"] = in.AsHolder
}
if in.Limit > 0 {
body["limit"] = in.Limit
}
if err := s.api.Post(ctx, "/api/who_knows", body, &report); err != nil {
return nil, whoKnowsOutput{}, err
}
return nil, whoKnowsOutput{Report: report}, nil
}
func (s *Server) askMemory(ctx context.Context, _ *mcp.CallToolRequest, in AskInput) (*mcp.CallToolResult, askOutput, error) {
if in.Question == "" {
return nil, askOutput{}, errors.New("question is required")
}
var answer domain.ReferralAnswer
body := map[string]any{"question": in.Question}
if in.AsHolder != "" {
body["as_holder"] = in.AsHolder
}
if in.MaxHops > 0 {
body["max_hops"] = in.MaxHops
}
if in.IncludeText {
body["include_text"] = true
}
if err := s.api.Post(ctx, "/api/ask", body, &answer); err != nil {
return nil, askOutput{}, err
}
return nil, askOutput{Answer: answer}, nil
}
func (s *Server) listHolders(ctx context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, holdersOutput, error) {
var holders []domain.Holder
if err := s.api.Get(ctx, "/api/holders", &holders); err != nil {
return nil, holdersOutput{}, err
}
return nil, holdersOutput{Holders: holders}, nil
}