package mcpsrv import ( "context" "errors" "fmt" "strings" "github.com/go-git/go-git/v5/plumbing" "sourcecraft.dev/bigbes/sr-ht-spec/core" "sourcecraft.dev/bigbes/sr-ht-spec/doc" "sourcecraft.dev/bigbes/sr-ht-spec/gitx" "sourcecraft.dev/bigbes/sr-ht-spec/search" "sourcecraft.dev/bigbes/sr-ht-spec/service" ) // Reader is the part of the orchestration layer the read tools call. // *service.Service satisfies it. // // It is deliberately four methods. Every one of them is the same function the // REST handlers, the GraphQL resolvers and the web UI call, which is what the // design means by "the MCP tools call the same resolver layer, not a parallel // implementation" — a fifth method here that service/ does not have would be // the beginning of a second implementation. // // ReadDocument is absent on purpose. Addressing a document by id needs the // space's whole document set anyway (see resolvePage), and reading the body // from that set rather than issuing a second, path-only read keeps one code // path instead of two that must agree about what an id names. type Reader interface { ListSpaces(ctx context.Context) ([]*service.Space, error) OpenSpace(ctx context.Context, ref core.SpaceRef) (*service.Space, error) ResolveRev(ctx context.Context, sp *service.Space, rev string) (string, error) ListDocuments(ctx context.Context, sp *service.Space, rev string) ([]service.Document, error) } // Searcher is the query side of the one global index. *search.Index satisfies // it. type Searcher interface { Search(ctx context.Context, q search.Query) (search.Results, error) } // Compile-time assertions that the production types satisfy the interfaces // this package is written against. They are here rather than in a test so that // a signature change in service/ or search/ breaks the build of the package // that depends on them, not of a test somebody may not run. var ( _ Reader = (*service.Service)(nil) _ Searcher = (*search.Index)(nil) ) const ( // minRevLen and maxRevLen bound a git object name. They are the design's // X-Agent-Base grammar — 7-64 lowercase hex — reused here so that "a // revision an agent may name" has one spelling across every surface. minRevLen = 7 maxRevLen = 64 ) // parseSpace resolves the tool's space argument, which is written the way the // design writes a space everywhere else: "~owner/name". func parseSpace(s string) (core.SpaceRef, error) { trimmed := strings.TrimSpace(s) if trimmed == "" { return core.SpaceRef{}, errors.New("space must not be empty; call spec_list with no arguments to list spaces") } ref, err := core.ParseSpaceRef(trimmed) if err != nil { return core.SpaceRef{}, fmt.Errorf("space %q: %w", trimmed, err) } return ref, nil } // parseRev turns the tool's optional rev argument into a revision string for // service/. An empty argument becomes service.ApprovedRev, which is the read // contract's default: the approved head. // // Anything else must be a git object name. Ref names are refused even though // service/ would happily resolve them, and that refusal is the whole point: // "proposals/42" is a legal revision one layer down, so accepting it here // would make unreviewed proposal content reachable by a plausible-looking // argument. Reading it requires naming a commit sha, which no read tool ever // hands back, so it cannot be reached by accident. func parseRev(s string) (string, error) { rev := strings.TrimSpace(s) if rev == "" { return service.ApprovedRev, nil } if len(rev) < minRevLen || len(rev) > maxRevLen { return "", fmt.Errorf("rev %q must be a git object name of %d-%d hex characters; "+ "omit rev to read the approved head", rev, minRevLen, maxRevLen) } for i := 0; i < len(rev); i++ { c := rev[i] if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') { continue } return "", fmt.Errorf("rev %q must be a git object name (lowercase hex), not a branch or ref name; "+ "omit rev to read the approved head", rev) } return rev, nil } // archiveAt reads a space at a revision and builds the addressable document // set, returning it together with the bodies keyed by path and the resolved // commit. // // The revision is resolved first and everything below is read at the resolved // sha, never at the caller's string. That is what makes an unpinned read // pinnable: the rev reported back names the exact bytes returned, so a merge // landing between the resolve and the read cannot make one answer describe two // revisions. // // The whole space is read for one document. At the confirmed volume — tens of // documents a day — that is cheap, and the alternative is worse: doc.Archive is // where the design's addressing rule (id when valid and unique, else path) // actually lives, and it is built from a revision's whole document set. A // path-only fast path would be a second addressing implementation. func archiveAt(ctx context.Context, b Backend, sp *service.Space, rev string) (*doc.Archive, map[string][]byte, string, error) { resolved, err := b.Docs.ResolveRev(ctx, sp, rev) if err != nil { return nil, nil, "", err } docs, err := b.Docs.ListDocuments(ctx, sp, resolved) if err != nil { return nil, nil, "", err } converted, err := toGitDocuments(docs) if err != nil { return nil, nil, "", err } arc := doc.FromDocuments(sp.Ref, resolved, converted) bodies := make(map[string][]byte, len(docs)) for _, d := range docs { bodies[d.Path] = d.Data } return arc, bodies, resolved, nil } // toGitDocuments converts service/'s hex-object-name documents back into the // shape doc.FromDocuments takes. // // This conversion should not exist, and it is the one place this package // reaches below service/. service.Document carries Blob and Rev as hex strings // precisely so that api/, mcpsrv/, graph/ and web/ need not import gitx — but // doc.FromDocuments, which owns the addressing rule those surfaces have to // apply, takes []gitx.Document. Until service/ exposes the archive itself, // something above it has to bridge the two, and doing it here in four lines is // better than restating the addressing rule in Go. A malformed sha is an // error, not a zero hash: plumbing.NewHash silently yields the zero value for // anything it cannot parse, and a document whose render-cache key is zero is a // cache collision waiting to happen. func toGitDocuments(docs []service.Document) ([]gitx.Document, error) { out := make([]gitx.Document, 0, len(docs)) for _, d := range docs { if !plumbing.IsHash(d.Blob) { return nil, fmt.Errorf("document %q carries a malformed blob id %q", d.Path, d.Blob) } out = append(out, gitx.Document{Path: d.Path, Blob: plumbing.NewHash(d.Blob), Data: d.Data}) } return out, nil } // resolvePage applies the design's addressing rule to one caller-supplied // token, using the archive's own lookups rather than reimplementing them: // // - a document with a well-formed id that no other document in its space // claims is addressed by that id; // - a document whose id is absent, malformed or duplicated is addressed by // its path — with or without the ".md" extension, since the read plane's // URL grammar carries no extension and an agent copying a path out of a // search hit has one. // // A token naming an id that two documents claim resolves to neither, and says // so. Silently picking one would point an agent at a document its author did // not mean, and the ambiguity would be invisible. func resolvePage(arc *doc.Archive, token string) (*doc.Page, error) { name := strings.TrimSpace(token) if name == "" { return nil, errors.New("document must not be empty") } if p, ok := arc.Page(name); ok { return p, nil } if p, ok := arc.ByPath(name); ok { return p, nil } if p, ok := arc.ByPath(name + ".md"); ok { return p, nil } if dup := duplicateIDPaths(arc, name); len(dup) > 1 { return nil, fmt.Errorf("document id %q is claimed by %d documents (%s) and resolves to none of them; "+ "address one of them by path instead", name, len(dup), strings.Join(dup, ", ")) } return nil, fmt.Errorf("no document %q in %s at %s", name, arc.Space, arc.Rev) } // duplicateIDPaths lists the paths of every document claiming id. doc/ excludes // a duplicated id from the archive's lookup, which is the correct resolution // but leaves nothing to report; this walk exists only to turn "not found" into // a message that names the collision. func duplicateIDPaths(arc *doc.Archive, id string) []string { var paths []string for _, p := range arc.All() { if p.DocID == id { paths = append(paths, p.Path) } } return paths }