package mcpsrv import ( "context" "errors" "fmt" "log/slog" "net/url" "strings" "time" "github.com/modelcontextprotocol/go-sdk/mcp" "go.bigb.es/auxilia/scribe" "sourcecraft.dev/bigbes/sr-ht-dolt/beads" "sourcecraft.dev/bigbes/sr-ht-dolt/core" ) // ready_work, the last tool of docs/DESIGN.mcp.md §9.2 and the only one on this // surface that answers about more than one database at a time. // // "What is ready to work" is answerable inside each beads tracker on this // instance and, without this, nowhere across them — which is the question the // split into a global tracker plus per-project trackers was supposed to make // askable. The /ready page (docs/DESIGN.views.md ch. 4) answers it for a human; // this answers it for an agent. // // # One aggregator, two renderings // // The set itself is beads.ReadyAcross's, unchanged and un-re-read: the same // function the page calls, over the same projection cache shape, with the same // three bounds (the head-hash gate, the TTL, the ceiling of // beads.ReadyMaxDatabases). Nothing about the ready rule, the grouping or the // ordering is restated here — two implementations would answer differently the // first time either moved, and the whole point of this tool is that the page and // the agent agree. // // What this file owns is the other half, which is exactly what beads/ refuses to // know: which databases this caller may see at all. That is ListReposForViewer // (the listing rule) followed by core.Allowed/OpBrowse per database (the access // rule), applied before a single store is opened — the same two steps // web/handlers_ready.go takes. // // # Two arms, one answer // // With a database named the tool answers about that one, resolved through the // preamble every beads tool shares (openTrackerFor): the masked not-found of a // database the caller may not read, the "not a beads tracker" refusal, the // default branch. With none named it answers across every tracker the caller may // see. Both arms then run through ReadyAcross with the same filter and the same // cache, so the two cannot disagree about one database: the named arm is the // cross arm restricted to a single candidate. // readyWorkInput addresses the tool's two arms and carries the page's own // filters. // // owner and name are optional *together*: naming neither is the cross-database // arm, naming both is one database, and naming one of the two is a call that // means nothing and is refused rather than guessed at. // // There is no ref argument, and that is not an omission. Across databases there // is no ref to name — a branch of one tracker says nothing about another — so // every database is read at its own default branch, exactly as the page reads // it. A tracker's other branches are list_issues' business. type readyWorkInput struct { Owner string `json:"owner,omitempty" jsonschema:"the database owner's SourceHut username, without the \"~\" (a leading one is accepted). Omit it — together with name — to read every tracker you can see."` Name string `json:"name,omitempty" jsonschema:"the database name, as list_databases reports it. Omit it — together with owner — to read every tracker you can see."` Query string `json:"q,omitempty" jsonschema:"a case-insensitive substring of an issue's id or title; it does not search bodies"` Assignee string `json:"assignee,omitempty" jsonschema:"an exact assignee"` Priority string `json:"priority,omitempty" jsonschema:"an exact priority as stored: \"0\" (highest) through \"3\""` Limit *int `json:"limit,omitempty" jsonschema:"how many ready issues to return across all databases, at most 500; defaults to 200. It is applied after filtering."` } // readyCardJSON is one ready issue. // // It is issueCardJSON minus the two fields that would be constants here: every // card in this answer is ready and every one of them is in the open status // category — that is what the ready rule selects — and a field whose value is // fixed by the tool's own name tells a caller nothing. There is no lane either: // the projection this reads produces the ready set, not the board's bucketing. // // No bodies, for the reason §9.2 gives once: a listing carries identity and // metadata, and get_issue carries the prose, one issue at a time. type readyCardJSON struct { ID string `json:"id"` Title string `json:"title"` Type string `json:"type"` Priority string `json:"priority"` Assignee string `json:"assignee"` Labels []string `json:"labels"` // BlockedBy is how many dependencies this issue has and Blocks how many point // at it. A ready issue can still have dependencies — a closed blocker is a // dependency that does not block — so BlockedBy is not always zero, and that // is worth seeing. BlockedBy int `json:"blocked_by"` Blocks int `json:"blocks"` } // readyHeadJSON is the head commit of the branch a database's ready set was read // from. // // It is carried per database and not per answer because it is the one fact that // makes the ready set checkable: a tracker that stopped receiving pushes still // has a ready set, and reporting it without saying how old the data is would be // exactly the claim this tool must not make silently. type readyHeadJSON struct { Hash string `json:"hash"` Time time.Time `json:"time"` } // readyDatabaseJSON is one tracker's ready work. type readyDatabaseJSON struct { // Owner and Name are the address every other tool on this surface takes, so // a caller can go straight from a card here to get_issue there. Owner string `json:"owner"` Name string `json:"name"` // Ref is the branch this database was read at — its own default branch. Ref string `json:"ref"` // Head is that branch's head commit, or null when the log could not be read. // Null is not a claim that the tracker is empty: the ready set beside it was // read at that very branch. Head *readyHeadJSON `json:"head"` // Ready is this database's ready issues, ordered by priority (0 first) then // id. Ready []readyCardJSON `json:"ready"` // Count is how many ready issues this database has after filtering, which is // not always how many are carried above: the answer's limit is spent across // databases, and this is the honest denominator per database. Count int `json:"count"` } // readyUnreadableJSON names a database that could not be read. // // It names it and nothing else: the underlying failure carries on-disk paths and // dolt internals, and it goes to the log (sr-ht-dolt-7ta). The address is not a // disclosure — it is a database this caller was already allowed to list. type readyUnreadableJSON struct { Owner string `json:"owner"` Name string `json:"name"` } type readyWorkOutput struct { // Databases carry the ready work, ordered by ready count descending then by // address — the page's own order. A database with nothing ready is absent // rather than present and empty. Databases []readyDatabaseJSON `json:"databases"` // Total is every ready issue that matched, across every database, before // Limit clipped the list — the honest denominator of the answer. Total int `json:"total"` // Limit is the limit that was applied, which is not always the one asked // for: a request above the cap is answered at the cap. Limit int `json:"limit"` // Truncated reports that matches were left behind by Limit. Narrow with q, // assignee or priority, or name one database. Truncated bool `json:"truncated"` // Considered is how many databases were actually read (or served from the // cache), and MaxDatabases is the ceiling one call may open. Considered int `json:"considered"` MaxDatabases int `json:"max_databases"` // Capped says there were more candidate databases than MaxDatabases and only // the first of them were read. A silent cap reads as "that is everything", // which is the one thing an answer about a *set* must never imply. Capped bool `json:"capped"` // Unreadable names the databases whose stores this service could not read. // They are part of the answer rather than a silent omission for Capped's // reason: the ready set below is complete only for the databases that are not // in this list. Unreadable []readyUnreadableJSON `json:"unreadable"` } // borrowedSession lends an already-open session to the aggregation without // handing over its lifetime. // // beads.ReadyAcross closes every session its opener produced, which is right // when the opener opened it. In the named arm the session was opened by the // handler — by the same preamble that resolved the database and checked the // fingerprint — and is closed by that handler's own defer, so the borrowed copy // must not close it a second time. A Close is not documented as idempotent // anywhere in this service, and a double close is the kind of thing that works // on a fake and corrupts a handle in production. type borrowedSession struct{ BrowseSession } func (borrowedSession) Close() error { return nil } // --- registration ----------------------------------------------------------- // registerReadyWork installs ready_work (docs/DESIGN.mcp.md §9.2, the // cross-database row). func (s *Server) registerReadyWork() { mcp.AddTool(s.mcp, &mcp.Tool{ Name: "ready_work", Annotations: readOnlyTool, Description: "Answer \"what can be picked up right now\" — bd's ready set: issues that are open, " + "unblocked and not templates or scaffolding.\n\n" + "**Omit `owner` and `name` and it answers across every beads tracker you can see**, grouped " + "by database, busiest first. That is what this tool is for: an instance holds one tracker per " + "project plus a global one, and this is the only way to ask all of them at once. Name both to " + "ask one tracker.\n\n" + "Each database carries its own `ref` and `head` — the branch the set was read from and that " + "branch's head commit with its time. Read them: a tracker that stopped receiving pushes still " + "has a ready set, and its age is the only thing that says so.\n\n" + "`q`, `assignee` and `priority` narrow the issues; `limit` (default 200, cap 500) is spent " + "across all databases after filtering, `total` is how many matched, and `truncated` says some " + "were left behind. Each database's `count` is its own match total.\n\n" + "Two facts keep the answer honest about being a set. `capped` says there were more trackers " + "than `max_databases` and only the first were read. `unreadable` names the databases whose " + "stores could not be read at all — the set is complete only for the databases not in it.\n\n" + "Cards carry no issue bodies; call get_issue with a card's `owner`, `name` and `id` for the " + "description, design and acceptance criteria. A database you name that is not a beads tracker " + "says so; one you may not read is reported as not existing.", }, func(ctx context.Context, _ *mcp.CallToolRequest, in readyWorkInput) (*mcp.CallToolResult, readyWorkOutput, error) { out, err := s.readyWork(ctx, in) return nil, out, err }) } // --- the handler ------------------------------------------------------------ // readyWork answers ready_work: the ready set of one named tracker, or of every // tracker the caller may see. // // The clock is real and is passed into the aggregation rather than read inside // it, exactly as listMemories passes its own: the TTL is the one thing about // this answer that depends on when it was computed, and beads/ reads no hidden // clock. func (s *Server) readyWork(ctx context.Context, in readyWorkInput) (readyWorkOutput, error) { const tool = "ready_work" var out readyWorkOutput limit, err := pageLimit(in.Limit, defaultIssueLimit, maxIssueLimit, "issues") if err != nil { return out, err } // The filter is parsed by beads rather than assembled here, for boardQuery's // reason: the substring rule and the trimming are the page's, not a second // implementation of them reading the same cards. filter := beads.ParseReadyFilter(readyQuery(in)) ref := databaseRef{Owner: in.Owner, Name: in.Name} named := ref.owner() != "" || ref.name() != "" var ( dbs []beads.ReadyDatabase open beads.ReadyOpener ) if named { var sess BrowseSession dbs, open, sess, err = s.readyOne(ctx, tool, ref) if err != nil { return out, err } defer sess.Close() } else if dbs, open, err = s.readyAll(ctx, tool); err != nil { return out, err } view := beads.ReadyAcross(ctx, dbs, open, s.ready, filter, time.Now()) // A store that cannot be read is a fact about this deployment and belongs in // the log with its cause; the answer names the database and not the failure. // // In the named arm it is not a partial answer at all: that database is the // whole subject of the call, its ref resolved and its fingerprint matched a // moment ago, so what failed afterwards is a table this service could not // read. Reporting an empty ready set there would be a false statement about // the tracker rather than a true one about the server. for _, f := range view.Failed { slog.Warn("reading a database for ready_work failed", "tool", tool, "database", f.Database.Slug(), scribe.Err(f.Err)) } if named && len(view.Failed) > 0 { return out, internalError(view.Failed[0].Err, tool) } out = readyWorkOutput{ Databases: make([]readyDatabaseJSON, 0, len(view.Groups)), Total: view.Total, Limit: limit, Considered: view.Considered, MaxDatabases: view.Max, Capped: view.Capped, Unreadable: make([]readyUnreadableJSON, 0, len(view.Failed)), } for _, f := range view.Failed { out.Unreadable = append(out.Unreadable, readyUnreadableJSON{ Owner: f.Database.OwnerName, Name: f.Database.Name, }) } // The limit is spent across databases in the order the aggregation produced // them, so what a clipped answer carries is the busiest trackers' highest // priorities rather than an arbitrary slice. A database left with nothing is // absent rather than present and empty — the same rule the aggregation // applies to a tracker with no ready work. carried := 0 for _, g := range view.Groups { entry := readyDatabaseJSON{ Owner: g.Database.OwnerName, Name: g.Database.Name, Ref: g.Ref, Count: len(g.Cards), Ready: make([]readyCardJSON, 0, len(g.Cards)), } if g.Head != nil { entry.Head = &readyHeadJSON{Hash: g.Head.Hash, Time: g.Head.Date} } for _, c := range g.Cards { if carried >= limit { break } entry.Ready = append(entry.Ready, readyCardOf(c)) carried++ } if len(entry.Ready) == 0 { continue } out.Databases = append(out.Databases, entry) } out.Truncated = carried < out.Total return out, nil } // readyOne is the named arm's candidate list: one database, resolved and opened // through the preamble every beads tool shares. // // Going through openTrackerFor is what makes this arm answer like the rest of // the surface without restating any of it — the masked not-found, the "not a // beads tracker" refusal, the default branch and the store that will not open // are all decided there. The session it returns is the caller's to close. func (s *Server) readyOne(ctx context.Context, tool string, ref databaseRef) ( []beads.ReadyDatabase, beads.ReadyOpener, BrowseSession, error, ) { if ref.owner() == "" || ref.name() == "" { return nil, nil, nil, errors.New( "address a database by both its owner and its name, as list_databases reports them — " + "or name neither, and this answers across every tracker you can see") } repo, sess, _, err := s.openTrackerFor(ctx, tool, ref, "") if err != nil { return nil, nil, nil, err } dbs := []beads.ReadyDatabase{{ID: repo.ID, OwnerName: repo.OwnerName, Name: repo.Name}} open := func(context.Context, beads.ReadyDatabase) (beads.ReadySession, error) { return borrowedSession{sess}, nil } return dbs, open, sess, nil } // readyAll is the cross-database arm's candidate list: every database this // caller may browse. // // It is two steps and they are not the same rule. ListReposForViewer applies the // *listing* rule (PUBLIC to everyone including anonymity, plus whatever the // caller owns or holds an ACL entry on), and core.Allowed then applies the // *access* rule per database. A database the caller may not browse is simply // absent — not a refusal, not a count, not a named group with hidden contents — // and it is dropped here, before any store is opened, so its very existence // costs nothing observable. // // An ACL lookup that fails takes the whole call with it rather than quietly // narrowing the answer. "I could not check" is not "you may not", and an agent // told that its tracker has no ready work believes it. func (s *Server) readyAll(ctx context.Context, tool string) ([]beads.ReadyDatabase, beads.ReadyOpener, error) { caller := callerOf(ctx) repos, err := s.repos.ListReposForViewer(ctx, caller) if err != nil { // No database was addressed, so there is no "that one does not exist" to // answer: whatever went wrong enumerating them is this service's. return nil, nil, internalError(err, tool) } // The on-disk path never reaches beads: it is this service's arrangement of // its own storage, and the aggregation addresses a database by the identity // its cache is keyed on. The opener closes over this map, so a database that // was filtered out above has no path to be opened by. paths := make(map[int]string, len(repos)) dbs := make([]beads.ReadyDatabase, 0, len(repos)) for _, repo := range repos { var mode *core.AccessMode if caller != nil { if mode, err = s.repos.EffectiveAccess(ctx, caller.UserID, repo.ID); err != nil { return nil, nil, internalError(err, tool) } } if !core.Allowed(caller, repo, mode, core.OpBrowse) { continue } paths[repo.ID] = repo.Path dbs = append(dbs, beads.ReadyDatabase{ ID: repo.ID, OwnerName: repo.OwnerName, Name: repo.Name, }) } open := func(ctx context.Context, d beads.ReadyDatabase) (beads.ReadySession, error) { path, ok := paths[d.ID] if !ok { return nil, fmt.Errorf("mcpsrv: no store path for database %s", d.Slug()) } return s.opener.Open(ctx, path) } return dbs, open, nil } // readyQuery renders the tool's filters as the query the page's filter parser // reads, so that what narrows this answer is beads.ReadyFilter and not a second // implementation of it applied to the same cards. // // The parser's ?db= is deliberately not offered: naming databases is what owner // and name already do, one at a time, through the resolution every other tool on // this surface uses. func readyQuery(in readyWorkInput) url.Values { q := url.Values{} set := func(key, value string) { if value = strings.TrimSpace(value); value != "" { q.Set(key, value) } } set("q", in.Query) set("assignee", in.Assignee) set("priority", in.Priority) return q } // readyCardOf projects one ready card. Labels are an array rather than a null, // so an agent can loop without a nil check. func readyCardOf(c beads.Card) readyCardJSON { labels := c.Labels if labels == nil { labels = []string{} } return readyCardJSON{ ID: c.ID, Title: c.Title, Type: c.Type, Priority: c.Priority, Assignee: c.Assignee, Labels: labels, BlockedBy: c.BlockedBy, Blocks: c.Blocks, } }