Files
ely-eye/ely-eye-mcp/internal/mcpserver/server.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

89 lines
3.1 KiB
Go

package mcpserver
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/apiclient"
"github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/config"
"github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/store"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
type Server struct {
cfg config.Config
store *store.Store
api *apiclient.Client
version string
}
func Run(ctx context.Context, cfg config.Config, version string) error {
db, err := store.Open(ctx, cfg)
if err != nil {
return err
}
defer db.Close()
server := &Server{cfg: cfg, store: db, api: apiclient.New(cfg.APIBaseURL), version: version}
mcpServer := mcp.NewServer(&mcp.Implementation{
Name: "ely-eye",
Title: "Ely-Eye Context Cartridge MCP",
Version: version,
}, &mcp.ServerOptions{
Instructions: "Use Ely-Eye tools to retrieve local cartridge context, evidence atoms, proof suites, and compiled evidence packs. Read tools mirror local Ely-Eye state without writing. remember, supersede_memory and forget_memory mutate memory through the Ely-Eye API, which must be running (`ely-eye serve`); the server, not the caller, decides who owns a memory. mount_cartridge and switch_cartridge decide what a search may see: mount a cartridge to ask only that cartridge, and unmount everything to ask the whole library.",
})
server.registerTools(mcpServer)
server.registerWriteTools(mcpServer)
server.registerDirectoryTools(mcpServer)
server.registerMountTools(mcpServer)
server.registerResources(mcpServer)
if err := mcpServer.Run(ctx, &mcp.StdioTransport{}); err != nil {
if errors.Is(err, io.EOF) || strings.HasSuffix(err.Error(), "EOF") {
return nil
}
return err
}
return nil
}
func (s *Server) registerResources(server *mcp.Server) {
addFileResource(server, "ely-eye://project/prd", "Ely-Eye PRD", filepath.Join(s.cfg.ProjectRoot, "PRD.md"))
addFileResource(server, "ely-eye://project/readme", "Ely-Eye README", filepath.Join(s.cfg.ProjectRoot, "README.md"))
addFileResource(server, "ely-eye://project/mcp-research", "Ely-Eye MCP research notes", filepath.Join(s.cfg.ProjectRoot, "ely-eye-mcp", "docs", "RESEARCH.md"))
}
// addFileResource registers a project document only when it exists on disk.
// External deployments rarely ship the Ely-Eye repository documents, so a
// missing file is a normal condition and the resource is simply omitted.
func addFileResource(server *mcp.Server, uri string, name string, path string) {
info, err := os.Stat(path)
if err != nil {
return
}
size := info.Size()
server.AddResource(&mcp.Resource{
URI: uri,
Name: name,
Title: name,
Description: "Local Ely-Eye project resource: " + path,
MIMEType: "text/markdown",
Size: size,
}, func(ctx context.Context, request *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
_ = ctx
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return &mcp.ReadResourceResult{
Contents: []*mcp.ResourceContents{{
URI: request.Params.URI,
MIMEType: "text/markdown",
Text: string(data),
}},
}, nil
})
}