package mcpsrv import ( "context" "errors" "fmt" "strings" "sourcecraft.dev/bigbes/sr-ht-spec/core" "sourcecraft.dev/bigbes/sr-ht-spec/doc" "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 three 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 fourth 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) // Archive resolves rev and returns the space's addressable document set at // it, with every document's bytes, from one tree walk. It replaced a // ResolveRev/ListDocuments pair here, and the conversion back into git // documents this package used to perform to reach doc.FromDocuments. Archive(ctx context.Context, sp *service.Space, rev string) (*doc.Archive, map[string][]byte, 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) _ Writer = (*service.Service)(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 returns its addressable document // set, the bodies keyed by path, and the resolved commit. // // service/ does the work: it resolves the revision first and reads 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) { arc, bodies, err := b.Docs.Archive(ctx, sp, rev) if err != nil { return nil, nil, "", err } return arc, bodies, arc.Rev, 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 }