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,
}
}