Files
ely-eye/ely-eye-mcp/internal/apiclient/client.go
T
ZacharyZhang-NY 9145a23f18 Require a token for writes and quarantine what nobody vouches for
Sixteen mutating routes now refuse a caller who cannot present the token the
server started with; queries stay open, because a question is not a write. The
environment variable is ELY_EYE_AUTH_TOKEN, following the repo's env_prefix
convention — the shorter name I reached for first returned 403 where the smoke
expected 401, and the server printed the reason.

Words from a stranger are quarantined on the way in: an HTTP upload is
third_party by definition, and a sentence carrying an instruction-shaped phrase
is quarantined whoever wrote it. A quarantined atom stays findable and stays out
of the compiled context until a second, live, unquarantined source agrees with
its rarest words. Corroboration is computed at read time because it is a
property of the corpus now, not of the moment a memory was written; it is
term-level, and the docstring says so.

forget now erases the source row and the compiled contexts beside the atoms, so
a forgotten memory leaves nothing that can still speak. The semantic cache key
carries a store revision, which fixes a real staleness: a context compiled
before new evidence arrived kept its old quarantine decision forever.

bench.audit --product still pointed at evals.py, which the p1-6 split moved into
backend/src/ely_eye/proofs/. It failed fast, but the anti-self-justification
rule had quietly stopped auditing the product; it now walks the package, and a
test pins that the package exists and holds no re-roll.

Gates: pytest 306 passed; ruff clean; mypy cold cache 24 errors in 9 files, the
recorded baseline, net zero; pyright 0 errors on the 23 backend files and the 2
bench files; go build/vet/test and gofmt clean; npm run build 221.39 kB with
eslint exit 0; bench/filesize_check clean; bench.audit --product clean over 31
harness files; scripts/mcp_write_smoke.sh PASS with the gate live across two
servers and the MCP child.
2026-10-08 23:05:15 -04:00

159 lines
4.6 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"
"os"
"strings"
"time"
"github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/domain"
)
const maxErrorBody = 4096
type Client struct {
BaseURL string
Token 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, "/"),
Token: strings.TrimSpace(os.Getenv("ELY_EYE_AUTH_TOKEN")),
HTTP: &http.Client{Timeout: 30 * time.Second},
}
}
// authorize attaches the write token; the server refuses a write without it.
func (c *Client) authorize(request *http.Request) {
if c.Token != "" {
request.Header.Set("Authorization", "Bearer "+c.Token)
}
}
// Get fetches a JSON resource into out.
func (c *Client) Get(ctx context.Context, path string, out any) error {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil)
if err != nil {
return fmt.Errorf("cannot build the request for %s: %w", path, err)
}
c.authorize(request)
return c.do(request, path, out)
}
func (c *Client) do(request *http.Request, path string, out any) error {
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
}
// 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")
c.authorize(request)
return c.do(request, path, out)
}
// 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
}
// SearchOptions is one ranked query. Empty optional fields are omitted, so the
// server's own defaults decide rather than a zero value invented here.
type SearchOptions struct {
Query string
Modality string
Holder string
Scenario string
Limit int
}
// SearchResult carries the server's own verdict: "no_match" is a claim about the
// corpus, an empty hit list is not.
type SearchResult struct {
Status string `json:"status"`
Hits []domain.AtomHit `json:"hits"`
NextCursor string `json:"next_cursor"`
}
// Search asks the running server to rank. The ranking lives in one place, so an
// MCP hit cannot disagree with an HTTP or CLI hit for the same query.
func (c *Client) Search(ctx context.Context, options SearchOptions) (*SearchResult, error) {
if strings.TrimSpace(options.Query) == "" {
return nil, errors.New("query is required")
}
body := map[string]any{"query": options.Query}
if options.Limit > 0 {
body["top_k"] = options.Limit
}
filters := map[string]string{}
for key, value := range map[string]string{
"modality": options.Modality,
"holder": options.Holder,
"scenario": options.Scenario,
} {
if value != "" {
filters[key] = value
}
}
if len(filters) > 0 {
body["filters"] = filters
}
var result SearchResult
if err := c.Post(ctx, "/api/search", body, &result); err != nil {
return nil, err
}
return &result, nil
}