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.
103 lines
3.8 KiB
Go
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
|
|
}
|