Files
ely-eye/ely-eye-mcp/internal/apiclient/client.go
T
ZacharyZhang-NY 3d792417cf 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.
2026-10-07 15:09:17 -04:00

85 lines
2.4 KiB
Go

// Package apiclient talks to a running Ely-Eye API server. The Python service
// owns every write rule, so the MCP server forwards mutations instead of
// duplicating them against SQLite.
package apiclient
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const maxErrorBody = 4096
type Client struct {
BaseURL string
HTTP *http.Client
}
func New(baseURL string) *Client {
if strings.TrimSpace(baseURL) == "" {
baseURL = "http://127.0.0.1:8765"
}
return &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
HTTP: &http.Client{Timeout: 30 * time.Second},
}
}
// Post sends body as JSON and decodes the response into out. A rejected request
// carries the server's own reason, which is the rule the caller broke.
func (c *Client) Post(ctx context.Context, path string, body any, out any) error {
payload, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("cannot encode the request for %s: %w", path, err)
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+path, bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("cannot build the request for %s: %w", path, err)
}
request.Header.Set("Content-Type", "application/json")
response, err := c.HTTP.Do(request)
if err != nil {
return fmt.Errorf("the Ely-Eye API at %s is unreachable; start it with `ely-eye serve`: %w", c.BaseURL, err)
}
defer response.Body.Close()
data, err := io.ReadAll(io.LimitReader(response.Body, maxErrorBody))
if err != nil {
return fmt.Errorf("%s returned an unreadable body: %w", path, err)
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return errors.New(path + " was rejected: " + serverReason(response.StatusCode, data))
}
if out == nil {
return nil
}
if err := json.Unmarshal(data, out); err != nil {
return fmt.Errorf("%s returned JSON that does not decode: %w", path, err)
}
return nil
}
// serverReason prefers FastAPI's {"detail": "..."} so the caller sees the broken
// rule instead of an HTML error page.
func serverReason(status int, data []byte) string {
var envelope struct {
Detail string `json:"detail"`
}
if err := json.Unmarshal(data, &envelope); err == nil && strings.TrimSpace(envelope.Detail) != "" {
return envelope.Detail
}
trimmed := strings.TrimSpace(string(data))
if trimmed == "" {
return fmt.Sprintf("HTTP %d with no reason", status)
}
return trimmed
}