A cartridge used to be an export: materialize it, hash it, and never let it
change behaviour again. The transactive-memory promise needs the opposite —
mounting the home cartridge must make the work cartridge's memories
unfindable, and appending to a cartridge must make a new memory findable
through it.
Cartridge scope (schema v2 -> v3):
- cartridge_atoms (which atoms are in which cartridge) and cartridge_mounts
(which cartridges this machine has mounted now); both rebuilt on reopen,
because a mount is a machine-local state, not a fact about the data.
- mount / switch / unmount / append refuse a cartridge that was built for a
different runtime, naming every differing field instead of a boolean.
- append rewrites atoms.parquet and sparse_index.bm25.zst, recomputes DNA and
the KV fingerprint, and keeps the cartridges row in one SQLite transaction;
the files are written first so a failed export cannot leave a half-cartridge
in the database.
- RetrievalService.search() now ranks inside the mounted scope by default, and
an explicit cartridge_ids filter overrides the mount. Nothing mounted means
the whole library, so an empty store behaves as it did before.
- forget() removes the forgotten atoms from every cartridge's membership:
forgetting must not leave a memory retrievable through a cartridge.
KV fingerprint (kv_fingerprint.py, stored in the manifest):
- model / tokenizer / chat-template hashes, the RoPE block (factor,
original_max_position_embeddings, mrope_interleaved, mrope_section,
partial_rotary_factor), the adapter hash, the context length, the engine
version, and the fingerprint format version.
- A snapshot we cannot read is recorded as unverifiable and listed in
unverifiable_fields; a blind machine refuses to mount rather than pretending
the runtime matches, because a wrong RoPE factor silently corrupts retrieval.
Two defects found by running the new tests, not by reading:
- atoms.parquet could not export a memory at all: pyarrow refuses a struct with
no child field, so metadata={} failed the whole table. The six JSON columns
are now written as JSON text and decoded by the cartridge reader.
- materialize() inserted cartridge membership before the cartridges row existed,
so the foreign key rejected every cartridge.
api.py crossed 500 lines, so the cartridge and mount routes moved to
api_cartridges.py. That module must not use `from __future__ import
annotations`: FastAPI then resolves Depends(settings_dep) against module
globals, where the injected dependency is only a parameter of register(), and
every route starts demanding settings as a query parameter.
Verification: 138 pytest passed (26 new: 10 fingerprint, 16 scope), Go
build/vet/gofmt/test clean, and scripts/mcp_write_smoke.sh now proves the whole
loop across two processes - mount narrows search, a memory written after the
upload is invisible until append_to_cartridge, unmount restores the library,
and rewriting the RoPE factor makes mount_cartridge refuse with "rope: factor:
cartridge 4.0 vs local 2.0". ruff clean on every touched file (the 20 remaining
package errors are in files this change did not touch; HEAD had 25). mypy: one
error, the pre-existing np.savez stub artifact at cartridge_assets.py:329
(HEAD:320). Repo pyright: 62 errors, unchanged from the p0-3 ledger.
88 lines
3.1 KiB
Go
88 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.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
|
|
})
|
|
}
|