Give the write path a CLI and an MCP face

remember / supersede / forget now reach the one Python writer from a
terminal, from MCP, and from HTTP. The server, not the caller, decides
who owns a memory.

- cli: remember, supersede, forget. --holder falls back to
  settings.default_holder until the holder registry lands. Timestamps are
  parsed once with one clear message.
- ingestion: import pymupdf as fitz. The deprecated fitz import printed a
  warning to stdout, so every CLI command's JSON answer was unparseable.
- mcp: remember, supersede_memory and forget_memory forward to the Python
  writer over HTTP (ELY_EYE_API, default http://127.0.0.1:8765). SQLite
  stays read-only to the Go side, so the write rules live in one place.
- mcp reads follow schema v2: atoms.time became ingested_at, and reads go
  through v_atoms_current, so a superseded memory stops being findable.
  holder, origin, scenario and event_time now show up in fetch_atom and
  search_atoms.
- scripts/mcp_write_smoke.sh proves the loop end to end: remember, search,
  supersede, forget, then not found.

Evidence: 92 pytest passed; go build, go vet and go test clean; mypy clean
on the 10 touched source files with a fresh cache; ruff clean except a
pre-existing B008 at cli.py:292, identical at HEAD.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-07 15:09:17 -04:00
1 parent 181f6bf57e
commit 3d792417cf
20 files changed
+1425 -33

No files matched your search

+5 -2
View File
@@ -8,6 +8,7 @@ import (
"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"
@@ -16,6 +17,7 @@ import (
type Server struct {
cfg config.Config
store *store.Store
api *apiclient.Client
version string
}
@@ -25,15 +27,16 @@ func Run(ctx context.Context, cfg config.Config, version string) error {
return err
}
defer db.Close()
server := &Server{cfg: cfg, store: db, version: version}
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. Tools read local Ely-Eye state and do not mutate the production database.",
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.",
})
server.registerTools(mcpServer)
server.registerWriteTools(mcpServer)
server.registerResources(mcpServer)
if err := mcpServer.Run(ctx, &mcp.StdioTransport{}); err != nil {
if errors.Is(err, io.EOF) || strings.HasSuffix(err.Error(), "EOF") {
@@ -0,0 +1,93 @@
package mcpserver
import (
"context"
"errors"
"github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/domain"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
type RememberInput struct {
Text string `json:"text" jsonschema:"the statement to remember, kept verbatim"`
Scenario string `json:"scenario,omitempty" jsonschema:"optional context label such as home or work"`
EventTime string `json:"event_time,omitempty" jsonschema:"when it was true, ISO 8601 with offset, e.g. 2026-05-01T12:00:00+08:00"`
EventTimePrecision string `json:"event_time_precision,omitempty" jsonschema:"instant, minute, hour, day, month, or year; required with event_time"`
RetentionUntil string `json:"retention_until,omitempty" jsonschema:"ISO 8601 with offset; the memory stops being retrievable after this"`
}
type SupersedeInput struct {
AtomID string `json:"atom_id" jsonschema:"exact Evidence Atom id being replaced"`
Text string `json:"text" jsonschema:"the newer statement, kept verbatim"`
Scenario string `json:"scenario,omitempty" jsonschema:"optional context label such as home or work"`
EventTime string `json:"event_time,omitempty" jsonschema:"when the new statement became true, ISO 8601 with offset"`
EventTimePrecision string `json:"event_time_precision,omitempty" jsonschema:"instant, minute, hour, day, month, or year; required with event_time"`
}
type ForgetInput struct {
AtomID string `json:"atom_id,omitempty" jsonschema:"exact Evidence Atom id to forget"`
SourceID string `json:"source_id,omitempty" jsonschema:"exact source id to forget, removing every atom of that source"`
Reason string `json:"reason,omitempty" jsonschema:"why this memory is gone"`
}
type RememberOutput struct {
Atom domain.Memory `json:"atom"`
}
type ForgetOutput struct {
Report domain.ForgetReport `json:"report"`
}
func (s *Server) registerWriteTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{
Name: "remember",
Title: "Remember",
Description: "Store a new memory. The Ely-Eye server stamps the holder, origin and ingestion time, so a caller cannot claim to be somebody else.",
}, s.rememberTool)
mcp.AddTool(server, &mcp.Tool{
Name: "supersede_memory",
Title: "Supersede a memory",
Description: "Replace a memory with a newer statement. The old one stops being retrievable but keeps its record, so the timeline stays readable.",
}, s.supersedeTool)
mcp.AddTool(server, &mcp.Tool{
Name: "forget_memory",
Title: "Forget a memory",
Description: "Delete a memory by atom_id or source_id and drop the derived indexes that would keep surfacing it.",
}, s.forgetTool)
}
func (s *Server) rememberTool(ctx context.Context, _ *mcp.CallToolRequest, input RememberInput) (*mcp.CallToolResult, RememberOutput, error) {
if input.Text == "" {
return nil, RememberOutput{}, errors.New("text is required")
}
var atom domain.Memory
if err := s.api.Post(ctx, "/api/memory", input, &atom); err != nil {
return nil, RememberOutput{}, err
}
return nil, RememberOutput{Atom: atom}, nil
}
func (s *Server) supersedeTool(ctx context.Context, _ *mcp.CallToolRequest, input SupersedeInput) (*mcp.CallToolResult, RememberOutput, error) {
if input.AtomID == "" {
return nil, RememberOutput{}, errors.New("atom_id is required")
}
if input.Text == "" {
return nil, RememberOutput{}, errors.New("text is required")
}
var atom domain.Memory
if err := s.api.Post(ctx, "/api/memory/supersede", input, &atom); err != nil {
return nil, RememberOutput{}, err
}
return nil, RememberOutput{Atom: atom}, nil
}
func (s *Server) forgetTool(ctx context.Context, _ *mcp.CallToolRequest, input ForgetInput) (*mcp.CallToolResult, ForgetOutput, error) {
if (input.AtomID == "") == (input.SourceID == "") {
return nil, ForgetOutput{}, errors.New("forget takes exactly one of atom_id or source_id")
}
var report domain.ForgetReport
if err := s.api.Post(ctx, "/api/memory/forget", input, &report); err != nil {
return nil, ForgetOutput{}, err
}
return nil, ForgetOutput{Report: report}, nil
}
@@ -0,0 +1,135 @@
package mcpserver
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/apiclient"
)
func recordingServer(t *testing.T, status int, body string, seen *[]string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
payload, _ := io.ReadAll(r.Body)
var decoded map[string]any
_ = json.Unmarshal(payload, &decoded)
*seen = append(*seen, r.URL.Path+" "+string(payload)+" "+func() string {
if decoded["text"] == nil {
return "<no text>"
}
return decoded["text"].(string)
}())
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
}
func TestRememberSendsTheStatementAndReturnsTheStampedMemory(t *testing.T) {
var seen []string
server := recordingServer(t, http.StatusCreated,
`{"atom_id":"mem-1:text","source_id":"mem-1","holder_id":"husband","origin":"self","text":"钥匙在玄关抽屉"}`, &seen)
defer server.Close()
s := &Server{api: apiclient.New(server.URL)}
_, out, err := s.rememberTool(context.Background(), nil, RememberInput{Text: "钥匙在玄关抽屉", Scenario: "home"})
if err != nil {
t.Fatalf("remember failed: %v", err)
}
if out.Atom.HolderID != "husband" || out.Atom.Origin != "self" {
t.Errorf("stamped memory = %+v, want the server's holder and origin", out.Atom)
}
if len(seen) != 1 || !strings.Contains(seen[0], "/api/memory") {
t.Fatalf("requests = %v, want one POST to /api/memory", seen)
}
if !strings.Contains(seen[0], "钥匙在玄关抽屉") {
t.Errorf("request = %s, want the statement verbatim", seen[0])
}
}
func TestRememberRefusesAnEmptyStatement(t *testing.T) {
var seen []string
server := recordingServer(t, http.StatusCreated, `{}`, &seen)
defer server.Close()
if _, _, err := (&Server{api: apiclient.New(server.URL)}).rememberTool(context.Background(), nil, RememberInput{}); err == nil {
t.Fatal("an empty memory must not be stored")
}
if len(seen) != 0 {
t.Errorf("requests = %v, want the empty write rejected before the network", seen)
}
}
func TestSupersedeNamesTheAtomItReplaces(t *testing.T) {
var seen []string
server := recordingServer(t, http.StatusOK,
`{"atom_id":"mem-2:text","supersedes":["mem-1:text"],"valid_from":"2026-05-02T00:00:00+00:00"}`, &seen)
defer server.Close()
s := &Server{api: apiclient.New(server.URL)}
_, out, err := s.supersedeTool(context.Background(), nil, SupersedeInput{AtomID: "mem-1:text", Text: "钥匙在客厅茶几"})
if err != nil {
t.Fatalf("supersede failed: %v", err)
}
if len(out.Atom.Supersedes) != 1 || out.Atom.Supersedes[0] != "mem-1:text" {
t.Errorf("supersedes = %v, want the replaced atom", out.Atom.Supersedes)
}
if !strings.Contains(seen[0], "/api/memory/supersede") {
t.Errorf("request = %s, want the supersede route", seen[0])
}
}
func TestSupersedeRefusesAMissingAtomID(t *testing.T) {
var seen []string
server := recordingServer(t, http.StatusOK, `{}`, &seen)
defer server.Close()
if _, _, err := (&Server{api: apiclient.New(server.URL)}).supersedeTool(context.Background(), nil, SupersedeInput{Text: "新的说法"}); err == nil {
t.Fatal("supersede must name the atom it replaces")
}
if len(seen) != 0 {
t.Errorf("requests = %v, want no write attempted", seen)
}
}
func TestForgetTakesExactlyOneID(t *testing.T) {
var seen []string
server := recordingServer(t, http.StatusOK, `{"source_id":"mem-1","atoms_deleted":1}`, &seen)
defer server.Close()
s := &Server{api: apiclient.New(server.URL)}
for _, input := range []ForgetInput{{}, {AtomID: "a:text", SourceID: "b"}} {
if _, _, err := s.forgetTool(context.Background(), nil, input); err == nil {
t.Errorf("input %+v accepted, want exactly one id", input)
}
}
_, out, err := s.forgetTool(context.Background(), nil, ForgetInput{SourceID: "mem-1", Reason: "moved house"})
if err != nil {
t.Fatalf("forget failed: %v", err)
}
if out.Report.AtomsDeleted != 1 {
t.Errorf("report = %+v, want the deleted count back", out.Report)
}
if len(seen) != 1 || !strings.Contains(seen[0], "/api/memory/forget") {
t.Errorf("requests = %v, want one POST to the forget route", seen)
}
}
func TestARejectedWriteReachesTheCallerAsAnError(t *testing.T) {
var seen []string
server := recordingServer(t, http.StatusUnprocessableEntity,
`{"detail":"mem-1:text was already closed"}`, &seen)
defer server.Close()
_, _, err := (&Server{api: apiclient.New(server.URL)}).supersedeTool(context.Background(), nil, SupersedeInput{AtomID: "mem-1:text", Text: "再说一次"})
if err == nil {
t.Fatal("a closed memory must not supersede silently")
}
if !strings.Contains(err.Error(), "already closed") {
t.Errorf("error = %v, want the server's reason", err)
}
}