package mcpsrv import ( "context" "errors" "fmt" "net/url" "strings" "time" "github.com/modelcontextprotocol/go-sdk/mcp" "sourcecraft.dev/bigbes/sr-ht-dolt/beads" ) // The beads-aware tools of docs/DESIGN.mcp.md §9.2: a hosted database that // carries the beads (bd) issue schema, read as issues rather than as tables. // // They add three rules to the ones browse.go states for the whole surface: // // - Nothing here reads the schema. Every answer below is a projection of // beads.Build / beads.BuildMilestones / beads.BuildMemories — the // fingerprint, the lane bucketing, the ready rule, the filters, the // dependency walk, the humanised history, the milestone rollup and the // memory revision walk are the ones the web board renders, and they are // shared on purpose (docs/DESIGN.mcp.md §2: no second reading of any schema). // A question this package could answer only by re-reading the tables is a // question it does not answer. // - The list/detail split is structural, not a habit. listIssuesOutput carries // issueCardJSON, which has no field a description, a design note, an // acceptance criterion or a comment could arrive in; the bodies are // get_issue's, one issue at a time. A board of 78 issues carrying every long // text is the agent's context window spent on text it did not ask for, which // is exactly what this surface exists to save. // - A database that is not a tracker is refused *per call*. MCP's tool list is // static per server, so all of them are advertised for every database on the // instance; one whose tables do not carry the fingerprint gets a sentence // naming the generic tools as the way to read it anyway. That refusal is an // ordinary answer about a database the caller can see — never the masked // not-found of a database it may not. // // What the projection reads and what it therefore cannot say: it reads up to // beads.Max (2000) rows per table in one pass. list_issues reports that clip // (table_truncated, table_total), because a board computed over a clipped table // is a count a caller cannot otherwise check. get_issue, list_milestones and // list_memories get no such signal from the projection and so make no claim // about one: an id that is not in the first beads.Max rows of a large tracker // reads as absent there, and list_issues is where a caller learns that the // tracker is that large. list_memories has a clip of an entirely different kind // and does report it — the revision walk's, which is about the history rather // than about a table (see memoryJSON.Revision). // The caps of docs/DESIGN.mcp.md §9.3 for the issue listing. They are applied // *after* filtering — the limit clips a result set, not a table read — and, like // every cap on this surface, the applied one is reported rather than assumed. const ( defaultIssueLimit = 200 maxIssueLimit = 500 ) // The three status categories the projection buckets a status into // (beads.statusCategory). They are this surface's filter vocabulary because they // are the only status grouping shared by every tracker: the status *names* are // per-database (a tracker may define its own in custom_statuses), so filtering on // one would be filtering on a string this service cannot enumerate. const ( categoryOpen = "open" categoryInProgress = "in_progress" categoryClosed = "closed" ) // --- the shapes a caller decodes ------------------------------------------- // issueCardJSON is one issue as a *listing* carries it: identity, metadata, and // the two counts and two flags a triage decision is made on. // // It carries no long text and it has no field one could arrive in — no // description, no design, no acceptance criteria, no notes, no comment. That is // the list/detail split of docs/DESIGN.mcp.md §9.2 made structural rather than // remembered: a board is identity and metadata, and the bodies are get_issue's, // one issue at a time. Adding such a field here would silently spend the context // window of every agent that lists a tracker, so this type is the place the rule // is enforced and the test asserts it over the serialised payload. // // Title is the exception that proves it: it is the issue's name, not its text. type issueCardJSON struct { ID string `json:"id"` Title string `json:"title"` // Type is the issue_type as stored ("task", "bug", "epic", "milestone", …), // and Priority is the raw priority ("0".."3", or "" when unset) rather than a // rendered label: an agent sorts on the number. 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. They are counts and not lists: the edges themselves are // get_issue's, with the titles and statuses that make them readable. BlockedBy int `json:"blocked_by"` Blocks int `json:"blocks"` // Ready is bd's ready set: open, unblocked, and not a template or an // ephemeral scaffold. It is the projection's rule, the same one the board // paints and `bd ready` prints. Ready bool `json:"ready"` // Lane is where the board places this issue — "Rolling", "Lined Up", // "Stalled" or "Past Stand" — which carries one thing Category does not: an // open issue with an open blocker is Stalled rather than Lined Up. Lane string `json:"lane"` // Category is the status category the lane was derived from: open, // in_progress or closed. It is what the status filter matches. Category string `json:"category"` } // issueFilter is the board's own filter model (beads.Filter) as tool arguments. // Every field is an exact match except q, and an unset field is no constraint. type issueFilter struct { Status string `json:"status,omitempty" jsonschema:"the status category: \"open\", \"in_progress\" or \"closed\". Individual status names are per-tracker and are not filterable; each issue's own status is on get_issue."` Type string `json:"type,omitempty" jsonschema:"an exact issue type, e.g. \"task\", \"bug\", \"epic\", \"milestone\""` Priority string `json:"priority,omitempty" jsonschema:"an exact priority as stored: \"0\" (highest) through \"3\""` Assignee string `json:"assignee,omitempty" jsonschema:"an exact assignee"` Label string `json:"label,omitempty" jsonschema:"a label the issue must carry, e.g. \"milestone:m3\""` Query string `json:"q,omitempty" jsonschema:"a case-insensitive substring of the issue's id or title; it does not search bodies"` Ready bool `json:"ready,omitempty" jsonschema:"true narrows to the ready set — open, unblocked, not a template. false is no constraint: there is no way to ask for the issues that are *not* ready."` } type listIssuesInput struct { databaseRef Ref string `json:"ref,omitempty" jsonschema:"a branch name or a commit hash to read the tracker at; omit it for the database's default branch"` Filter issueFilter `json:"filter,omitempty" jsonschema:"narrows the listing; every field is optional and an omitted one is no constraint"` Limit *int `json:"limit,omitempty" jsonschema:"how many issues to return, at most 500; defaults to 200. It is applied after filtering."` } type listIssuesOutput struct { // Ref is the ref actually read, which is the default branch when the call // named none. Ref string `json:"ref"` // Issues are in the board's own parade order: Rolling, then Lined Up, then // Stalled, then Past Stand, and within each by priority, then age, then id. Issues []issueCardJSON `json:"issues"` // Total is how many issues matched the filter, before Limit clipped them — // the honest denominator of the list above. 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 the // filter or raise the limit; this tool does not page, because a filtered // board is meant to be small. Truncated bool `json:"truncated"` // TableTruncated reports the *other* clip, and it is a separate flag because // it is a different fact: the projection reads at most 2000 rows of a table // in one pass (docs/DESIGN.mcp.md §9.3), so on a tracker larger than that the // board above — and every count on it — was computed over the first 2000 // issues rather than over all of them. Nothing here pages past it; read_rows // does, if the whole table is really wanted. TableTruncated bool `json:"table_truncated"` // TableTotal is the number of rows in the issues table at this ref, which is // what makes TableTruncated checkable rather than a bare warning. TableTotal int `json:"table_total"` } type getIssueInput struct { databaseRef ID string `json:"id" jsonschema:"the issue id, as list_issues reports it (e.g. \"bd-42\")"` Ref string `json:"ref,omitempty" jsonschema:"a branch name or a commit hash to read the tracker at; omit it for the database's default branch"` } // issueJSON is the whole issue: every field the projection models, bodies // included. This is the detail half of the split issueCardJSON is the list half // of — one issue at a time, because that is what makes the long texts // affordable. type issueJSON struct { ID string `json:"id"` Title string `json:"title"` Status string `json:"status"` IssueType string `json:"issue_type"` Priority string `json:"priority"` // Lane is the board lane this issue's status category maps to. It is the // detail pane's lane and is derived from the status alone, so an open issue // with an open blocker reads "Lined Up" here while list_issues places it in // "Stalled" — the blocked signal is in DependsOn, which this answer carries // in full. Lane string `json:"lane"` Assignee string `json:"assignee"` CreatedBy string `json:"created_by"` Owner string `json:"owner"` EstimatedMinutes string `json:"estimated_minutes"` ExternalRef string `json:"external_ref"` SpecID string `json:"spec_id"` // The four long texts bd carries for an issue. They are why get_issue exists // and why no listing on this surface has them. Description string `json:"description"` Design string `json:"design"` AcceptanceCriteria string `json:"acceptance_criteria"` Notes string `json:"notes"` // The timestamps as stored ("YYYY-MM-DD HH:MM:SS"), unparsed: this surface // reads a bare store without a SQL engine, so what it has is the stored // string, and re-typing it as a time would be a claim about a timezone // nobody recorded. CreatedAt string `json:"created_at"` StartedAt string `json:"started_at"` UpdatedAt string `json:"updated_at"` ClosedAt string `json:"closed_at"` CloseReason string `json:"close_reason"` Labels []string `json:"labels"` } // edgeJSON is one direct dependency edge, with the other end resolved to // something readable. type edgeJSON struct { IssueID string `json:"issue_id"` Title string `json:"title"` // Type is the dependency type — "blocks", "parent-child", "related", … — // and it matters: only an open "blocks" edge blocks, while parent-child is // hierarchy. Type string `json:"type"` Status string `json:"status"` Closed bool `json:"closed"` } // treeNodeJSON is one node of a flattened transitive dependency tree. Depth is // the indentation level: 0 is a direct edge of the issue asked about. type treeNodeJSON struct { ID string `json:"id"` Title string `json:"title"` Type string `json:"type"` Status string `json:"status"` Closed bool `json:"closed"` Depth int `json:"depth"` } // subtaskJSON is one child of an epic — the far end of a parent-child edge // pointing at it. type subtaskJSON struct { ID string `json:"id"` Title string `json:"title"` Status string `json:"status"` Category string `json:"category"` Priority string `json:"priority"` Assignee string `json:"assignee"` Blocked bool `json:"blocked"` } type commentJSON struct { Author string `json:"author"` Text string `json:"text"` CreatedAt string `json:"created_at"` } // activityJSON is one entry of the merged history: a comment, an audit event, or // a dependency link (which beads records on the edge row rather than as an // event). Summary is the humanised one-liner the projection builds // ("changed status to in_progress"); Text carries a comment's body or an event's // free-text note. type activityJSON struct { Kind string `json:"kind"` Event string `json:"event"` Actor string `json:"actor"` Summary string `json:"summary"` Text string `json:"text"` CreatedAt string `json:"created_at"` } type getIssueOutput struct { Ref string `json:"ref"` Issue issueJSON `json:"issue"` // IsEpic reports that this issue is a parent of subtasks, which is what makes // the two rollup counts below meaningful. IsEpic bool `json:"is_epic"` // DependsOn is what this issue waits on; DependedOnBy is what waits on it. // Both are the direct edges only. DependsOn []edgeJSON `json:"depends_on"` DependedOnBy []edgeJSON `json:"depended_on_by"` // The transitive closures of those two directions, flattened pre-order with // a depth. They are bounded by the projection (6 levels, 200 nodes) so a // dense or cyclic graph cannot run away, and they are empty when they would // only repeat the direct edges above. DependsTree []treeNodeJSON `json:"depends_tree"` DependentTree []treeNodeJSON `json:"dependent_tree"` // The epic rollup: the children, how many are closed, and how many there // are. Empty and zero for an issue that is not an epic. Subtasks []subtaskJSON `json:"subtasks"` SubtaskDone int `json:"subtask_done"` SubtaskTotal int `json:"subtask_total"` Comments []commentJSON `json:"comments"` // History is the comments and the audit trail merged and sorted oldest // first, which is the one place the *story* of an issue is readable. History []activityJSON `json:"history"` } type listMilestonesInput struct { databaseRef Ref string `json:"ref,omitempty" jsonschema:"a branch name or a commit hash to read the tracker at; omit it for the database's default branch"` } // milestoneMemberJSON is one issue under a milestone. // // It is a smaller shape than issueCardJSON on purpose rather than by omission: // the milestone rollup does not compute the dependency counts or the ready flag, // and reporting them as 0 and false here would be four lies an agent has no way // to detect. Call list_issues with filter.label to get the full cards for a // milestone's members. type milestoneMemberJSON struct { ID string `json:"id"` Title string `json:"title"` Type string `json:"type"` Priority string `json:"priority"` Assignee string `json:"assignee"` Category string `json:"category"` } // milestoneEpicJSON is an epic inside a milestone with the members nested under // it. A child nests only when it carries the same milestone label as its epic — // membership is purely label-based. type milestoneEpicJSON struct { Issue milestoneMemberJSON `json:"issue"` Done int `json:"done"` Total int `json:"total"` Children []milestoneMemberJSON `json:"children"` } // milestoneJSON is one "milestone:" label's rollup and its members. type milestoneJSON struct { Name string `json:"name"` Label string `json:"label"` // The arithmetic of the rollup: Total is every issue carrying the label, and // the three below partition it by status category. Total int `json:"total"` Done int `json:"done"` InProgress int `json:"in_progress"` Open int `json:"open"` // The members, in the shallow hierarchy the projection arranges them in: // the milestone's own issue(s) first, then its epics with their children // nested, then everything else. Every member appears exactly once across the // three, and the three together are Total issues. Heads []milestoneMemberJSON `json:"heads"` Epics []milestoneEpicJSON `json:"epics"` Loose []milestoneMemberJSON `json:"loose"` } type listMilestonesOutput struct { Ref string `json:"ref"` Milestones []milestoneJSON `json:"milestones"` // Unlabeled is how many issues carry no milestone label at all, and Total is // every issue read. A tracker that uses no milestone labels answers an empty // list with Unlabeled == Total, which is an answer and not an error. Unlabeled int `json:"unlabeled"` Total int `json:"total"` } type listMemoriesInput struct { databaseRef Ref string `json:"ref,omitempty" jsonschema:"a branch name or a commit hash to read the tracker at; omit it for the database's default branch"` // Query is the memory view's ?q= and is handed to the projection as such, // rather than applied to the answer here: filtering afterwards would be a // second reading of the same rule, and it would also make the tool pay for // dating memories it is about to drop (the revision walk runs per key, after // the narrowing). Query string `json:"q,omitempty" jsonschema:"a case-insensitive substring of a memory's slug or of its text; omit it for every memory the tracker holds"` } // memoryRevisionJSON is when a memory's value last changed: the commit that // wrote it, recovered from the history rather than read off the row — a config // row is (key, value) and carries no timestamp at all // (docs/DESIGN.views.md §2.1). type memoryRevisionJSON struct { Commit string `json:"commit"` Date time.Time `json:"date"` Author string `json:"author"` } // memoryJSON is one `bd remember` entry: the slug, the text, and what the walk // could establish about when it was written. // // This is the one listing on this surface that carries prose, and it is not an // exception to the list/detail split of §9.2 — it is the same rule applied. A // memory *is* its text: there is no detail tool to send a caller to, and a // listing of slugs alone would answer nothing. `q` is how a caller reads part of // a large tracker's memories rather than all of them. type memoryJSON struct { // Slug is the config key with bd's "kv.memory." prefix stripped, and Text is // the value with both newline spellings normalised — memories are typed into // shell strings as often as into files, so the same tracker holds real // newlines and literal "\n" escapes side by side. Slug string `json:"slug"` Text string `json:"text"` // Revision is the commit that last wrote this value, or **null** when the // revision walk could not reach it. Null is not "unknown for some reason": it // occurs only when walk_truncated is true, and it means this memory was not // written inside the last walk_max commits — i.e. it is older than that. (Not // the converse: a truncated walk can still have dated every memory it was // asked about.) Inventing a date the walk cannot support, or dropping the // field, would both turn that fact into something a caller cannot see. Revision *memoryRevisionJSON `json:"revision"` // AgeDays is how long ago that revision was, in whole days, measured against // the server's clock when the call was answered. It is null exactly when // Revision is. // // It is carried beside the date rather than left to the caller because it is // what Stale is computed from, and a flag whose input is invisible is a flag // that has to be trusted. AgeDays *int `json:"age_days"` // Stale is the projection's question — not a verdict — about a memory older // than stale_after_days: some memories are meant to be permanent, and only the // reader knows which. // // It can be true while Revision is null, and that is not a contradiction: when // the walk's own oldest commit is already past the threshold, the memory is at // least that old, and that much is known without a date. Stale bool `json:"stale"` } type listMemoriesOutput struct { Ref string `json:"ref"` // Memories are ordered by slug. Memories []memoryJSON `json:"memories"` // Total is how many memories the tracker holds before `q` narrowed them — the // honest denominator of the list above, and the way a caller tells "this // tracker has none" from "your search matched none". Total int `json:"total"` // WalkTruncated says the revision walk stopped at WalkMax commits with the // history still going, which is the only way a memory here carries no // revision. Read it with the null revisions above: it is the sentence "older // than the last walk_max commits" that the page renders in place of a date. WalkTruncated bool `json:"walk_truncated"` // WalkMax is how many commits back the walk looks, so the sentence above can // name its own number instead of asking a caller to trust a bound it cannot // see. WalkMax int `json:"walk_max"` // StaleAfterDays is the threshold every Stale flag was computed against. It is // one constant for the instance rather than a per-call knob, and it is // published for the same reason age_days is: a caller that disagrees with the // threshold can apply its own to the ages. StaleAfterDays int `json:"stale_after_days"` } // --- registration ----------------------------------------------------------- // registerBeadsTools installs the tools of docs/DESIGN.mcp.md §9.2. // // The descriptions tell an agent choosing between them what each one costs: the // listing is cheap and carries no bodies, the detail is one issue and carries // all of them. An agent that reads that stops asking for a board it will not // read. func (s *Server) registerBeadsTools() { mcp.AddTool(s.mcp, &mcp.Tool{ Name: "list_issues", Annotations: readOnlyTool, Description: "List the issues of a beads (bd) issue tracker hosted here, as cards: id, title, type, " + "priority, assignee, labels, how many dependencies the issue has and how many point at it, " + "whether it is ready to work, and which board lane it sits in.\n\n" + "**No bodies.** Descriptions, design notes, acceptance criteria and comments are not in this " + "answer at all — call get_issue for one issue when you need them. That split is what makes " + "listing a whole tracker affordable.\n\n" + "`filter` narrows the listing and every field is optional: `status` is the category " + "(`open`, `in_progress`, `closed`), `type`, `priority`, `assignee` and `label` are exact " + "matches, `q` is a case-insensitive substring of the id or title, and `ready: true` narrows " + "to what can be worked now — open, unblocked, not a template. That is bd's own ready set, " + "the answer to \"what should I pick up\".\n\n" + "`limit` defaults to 200, is capped at 500 and is applied after filtering; `total` is how " + "many issues matched before it, and `truncated` says matches were left behind. Separately, " + "`table_truncated` says the tracker has more than 2000 issues and only the first 2000 were " + "read, so the counts describe that prefix — `table_total` is the real number.\n\n" + "A database that is not a beads tracker says so and points at the generic tools; a database " + "you may not read is reported as not existing.", }, func(ctx context.Context, _ *mcp.CallToolRequest, in listIssuesInput) (*mcp.CallToolResult, listIssuesOutput, error) { out, err := s.listIssues(ctx, in) return nil, out, err }) mcp.AddTool(s.mcp, &mcp.Tool{ Name: "get_issue", Annotations: readOnlyTool, Description: "Read one issue of a hosted beads tracker whole: every modelled field including the four " + "long texts (description, design, acceptance criteria, notes), both dependency directions " + "with the titles and statuses of the other ends, the transitive dependency trees, the " + "comments, and the merged history of comments and audit events oldest first.\n\n" + "`id` is the issue id list_issues reports, e.g. \"bd-42\". An id the tracker does not carry " + "is answered as such — about this database, which you are looking straight at.\n\n" + "When the issue is an epic, `is_epic` is true and `subtasks` carries its children with " + "`subtask_done`/`subtask_total` as the rollup. `depends_on` is what this issue waits on and " + "`depended_on_by` is what waits on it; only an open `blocks` edge actually blocks, while " + "`parent-child` is hierarchy. The two trees flatten those directions transitively with a " + "`depth`, and are empty when they would only repeat the direct edges.\n\n" + "This is the only tool on this surface that carries issue bodies. Use list_issues to find " + "the id, then this one for the issue you actually need.", }, func(ctx context.Context, _ *mcp.CallToolRequest, in getIssueInput) (*mcp.CallToolResult, getIssueOutput, error) { out, err := s.getIssue(ctx, in) return nil, out, err }) mcp.AddTool(s.mcp, &mcp.Tool{ Name: "list_milestones", Annotations: readOnlyTool, Description: "Summarize a hosted beads tracker by milestone: for every \"milestone:\" label, how " + "many issues carry it and how many of those are done, in progress and open, plus the " + "members themselves.\n\n" + "Members are arranged the way the tracker means them: `heads` are the milestone's own " + "issues (issue_type \"milestone\"), `epics` are its epics with their children nested and " + "their own done/total, and `loose` is everything else. Each member appears exactly once " + "across the three, and together they are `total`.\n\n" + "Membership is purely label-based: an issue in several milestones counts under each, and a " + "child nests under an epic only when it carries the same milestone label. `unlabeled` is " + "how many issues carry no milestone label at all.\n\n" + "A tracker that uses no milestone labels answers an empty list — that is an answer, not an " + "error. For the full cards of one milestone's members, call list_issues with " + "`filter.label` set to the label.", }, func(ctx context.Context, _ *mcp.CallToolRequest, in listMilestonesInput) (*mcp.CallToolResult, listMilestonesOutput, error) { out, err := s.listMilestones(ctx, in) return nil, out, err }) mcp.AddTool(s.mcp, &mcp.Tool{ Name: "list_memories", Annotations: readOnlyTool, Description: "List the memories a hosted beads tracker holds — what `bd remember` writes — each with its " + "text and the revision its value was last written at.\n\n" + "Memories are the other half of what a tracker knows: durable notes an agent left for the next " + "one, stored as `kv.memory.` rows in the tracker's `config` table. Read them before " + "planning work on a tracker; they are where its conventions, its gotchas and its handoffs live.\n\n" + "A memory carries no timestamp — the row is (key, value) and nothing else — so `revision` is " + "recovered from the history: the commit whose `config` table first differs is the one that " + "wrote the value. That walk looks back at most `walk_max` commits. A memory not written inside " + "it has `revision: null` and `age_days: null`, and `walk_truncated` is true: null means \"older " + "than the last `walk_max` commits\", never \"date unavailable\".\n\n" + "`stale` is a question, not a verdict — it marks a memory older than `stale_after_days`, and " + "some memories are meant to be permanent. Both the age and the threshold are in the answer, so " + "judge for yourself rather than trusting the flag. A memory can be `stale` with a null " + "revision: the walk's oldest commit is already past the threshold.\n\n" + "`q` is a case-insensitive substring of a slug or of a memory's text; `total` is how many " + "memories the tracker holds before it narrowed them. A tracker whose `config` table holds no " + "memory — or that has no `config` table at all — answers an empty list, which is an answer and " + "not an error.\n\n" + "A database that is not a beads tracker says so and points at the generic tools; a database " + "you may not read is reported as not existing.", }, func(ctx context.Context, _ *mcp.CallToolRequest, in listMemoriesInput) (*mcp.CallToolResult, listMemoriesOutput, error) { out, err := s.listMemories(ctx, in) return nil, out, err }) } // --- the handlers ----------------------------------------------------------- // listIssues answers list_issues: the board's cards, filtered, ordered and // capped. func (s *Server) listIssues(ctx context.Context, in listIssuesInput) (listIssuesOutput, error) { const tool = "list_issues" var out listIssuesOutput limit, err := pageLimit(in.Limit, defaultIssueLimit, maxIssueLimit, "issues") if err != nil { return out, err } // An unrecognised category is refused rather than matched against nothing: a // caller that typed "in-progress" would otherwise read an empty board as "no // work is under way", which is a false statement about the tracker. wantCategory, err := parseCategory(in.Filter.Status) if err != nil { return out, err } sess, ref, err := s.openTracker(ctx, tool, in.databaseRef, in.Ref) if err != nil { return out, err } defer sess.Close() data, err := beads.Build(ctx, sess, ref, boardQuery(in.Filter)) if err != nil { // The ref resolved and the fingerprint matched a moment ago (openTracker), // so a failure here is a table this service could not read rather than a // question about a ref or a database. return out, internalError(err, tool) } out = listIssuesOutput{ Ref: ref, Issues: []issueCardJSON{}, Limit: limit, TableTruncated: data.Truncated, TableTotal: data.ShownOf, } for _, lane := range data.Lanes { category, ok := laneCategory(lane.Slug) if !ok { // The slugs are the projection's own. An unknown one means beads changed // its lanes and this mapping did not, and answering with a guessed // category would be this surface quietly disagreeing with the board. return listIssuesOutput{}, internalError( fmt.Errorf("the beads projection reported an unknown lane %q", lane.Slug), tool) } if wantCategory != "" && category != wantCategory { continue } for _, c := range lane.Issues { out.Total++ if len(out.Issues) >= limit { continue } labels := c.Labels if labels == nil { labels = []string{} } out.Issues = append(out.Issues, issueCardJSON{ ID: c.ID, Title: c.Title, Type: c.Type, Priority: c.Priority, Assignee: c.Assignee, Labels: labels, BlockedBy: c.BlockedBy, Blocks: c.Blocks, Ready: c.Ready, Lane: lane.Name, Category: category, }) } } out.Truncated = out.Total > len(out.Issues) return out, nil } // getIssue answers get_issue: one issue whole, bodies included. func (s *Server) getIssue(ctx context.Context, in getIssueInput) (getIssueOutput, error) { const tool = "get_issue" var out getIssueOutput id := strings.TrimSpace(in.ID) if id == "" { return out, errors.New("name the issue to read; list_issues reports the ids of a tracker") } sess, ref, err := s.openTracker(ctx, tool, in.databaseRef, in.Ref) if err != nil { return out, err } defer sess.Close() data, err := beads.Build(ctx, sess, ref, url.Values{"issue": {id}}) if err != nil { return out, internalError(err, tool) } if data.Issue == nil { // An ordinary answer about a database the caller can see, in refMiss's // sense: naming the id back is not a leak, it is what the caller asked // with. return out, errors.New(noSuchIssue(in.databaseRef, ref, id)) } out = getIssueOutput{ Ref: ref, Issue: issueOf(data.Issue), IsEpic: data.Mode == "epic", DependsOn: edgesOf(data.DependsOn), DependedOnBy: edgesOf(data.DependedOnBy), DependsTree: treeOf(data.DependsTree), DependentTree: treeOf(data.DependentTree), Subtasks: make([]subtaskJSON, 0, len(data.Subtasks)), SubtaskDone: data.SubtaskDone, SubtaskTotal: data.SubtaskTotal, Comments: make([]commentJSON, 0, len(data.Comments)), History: make([]activityJSON, 0, len(data.History)), } for _, st := range data.Subtasks { out.Subtasks = append(out.Subtasks, subtaskJSON{ ID: st.ID, Title: st.Title, Status: st.Status, Category: st.Category, Priority: st.Priority, Assignee: st.Assignee, Blocked: st.Blocked, }) } for _, c := range data.Comments { out.Comments = append(out.Comments, commentJSON{Author: c.Author, Text: c.Text, CreatedAt: c.CreatedAt}) } for _, a := range data.History { out.History = append(out.History, activityJSON{ Kind: a.Kind, Event: a.Event, Actor: a.Actor, Summary: a.Summary, Text: a.Text, CreatedAt: a.CreatedAt, }) } return out, nil } // listMilestones answers list_milestones: the milestone: labels rolled up. func (s *Server) listMilestones(ctx context.Context, in listMilestonesInput) (listMilestonesOutput, error) { const tool = "list_milestones" var out listMilestonesOutput sess, ref, err := s.openTracker(ctx, tool, in.databaseRef, in.Ref) if err != nil { return out, err } defer sess.Close() view, err := beads.BuildMilestones(ctx, sess, ref) if err != nil { return out, internalError(err, tool) } out = listMilestonesOutput{ Ref: ref, Milestones: make([]milestoneJSON, 0, len(view.Milestones)), Unlabeled: view.Unlabeled, Total: view.Total, } for _, m := range view.Milestones { entry := milestoneJSON{ Name: m.Name, Label: m.Label, Total: m.Total, Done: m.Done, InProgress: m.InProgress, Open: m.Open, Heads: membersOf(m.Heads), Epics: make([]milestoneEpicJSON, 0, len(m.Epics)), Loose: membersOf(m.Loose), } for _, e := range m.Epics { entry.Epics = append(entry.Epics, milestoneEpicJSON{ Issue: memberOf(e.Card), Done: e.Done, Total: e.Total, Children: membersOf(e.Children), }) } out.Milestones = append(out.Milestones, entry) } return out, nil } // listMemories answers list_memories: the memories a tracker holds, each dated // from the history by beads.BuildMemories. // // The clock is real (time.Now) and is passed into the projection rather than // read inside it, exactly as the web view passes its own: staleness is the one // thing about this answer that depends on when it was computed, and beads/ // reads no hidden clock. // // # Why the fingerprint here is still beads.Applies // // beads.AppliesMemories exists — the beads fingerprint plus a config table with // key and value — and it is what decides whether the web board grows a Memory // tab. It is *not* what this tool refuses on, and the difference matters. // openTracker's refusal says "this database is not a beads issue tracker", and // for a tracker whose config table simply is not there that sentence would be // false: it is a tracker, it has no memories, and "no memories" is an answer the // projection already gives (a missing config table degrades to an empty view, // like every other optional table). A tab is a question about a page's layout; a // tool call is a question about the data, and the data here is "none". func (s *Server) listMemories(ctx context.Context, in listMemoriesInput) (listMemoriesOutput, error) { const tool = "list_memories" var out listMemoriesOutput sess, ref, err := s.openTracker(ctx, tool, in.databaseRef, in.Ref) if err != nil { return out, err } defer sess.Close() // BrowseSession's method set covers beads.MemorySession (Rows, plus Log and // TableHash, which the walk needs and only it needs), so the seam is handed // over as it is rather than adapted. now := time.Now() view, err := beads.BuildMemories(ctx, sess, ref, memoryQuery(in.Query), now) if err != nil { // The ref resolved and the fingerprint matched a moment ago, so what failed // here is a table or a history this service could not read. In particular a // history that cannot be walked is an error and not a page of memories with // every date quietly missing — that page is indistinguishable from a tracker // whose memories are all older than the walk. return out, internalError(err, tool) } out = listMemoriesOutput{ Ref: ref, Memories: make([]memoryJSON, 0, len(view.Memories)), Total: view.Total, WalkTruncated: view.WalkTruncated, WalkMax: view.WalkMax, StaleAfterDays: int(beads.MemoryStaleAfter / (24 * time.Hour)), } for _, m := range view.Memories { entry := memoryJSON{Slug: m.Slug, Text: m.Text, Stale: m.Stale} if m.Revision != nil { entry.Revision = &memoryRevisionJSON{ Commit: m.Revision.Commit, Date: m.Revision.Date, Author: m.Revision.Author, } // Whole days, measured against the same clock the projection judged // Stale with — two answers from one reading rather than two. days := int(now.Sub(m.Revision.Date) / (24 * time.Hour)) entry.AgeDays = &days } out.Memories = append(out.Memories, entry) } return out, nil } // --- resolving a tracker ---------------------------------------------------- // openTracker is the whole preamble of a beads tool: resolve the database as the // browse handlers do, open its store, settle the ref, and refuse a database that // is not a tracker. // // The session is the caller's to close (defer sess.Close()), which is the browse // discipline; returning it rather than a closure keeps that visible at the call // site instead of hidden in a helper. func (s *Server) openTracker(ctx context.Context, tool string, ref databaseRef, named string) (BrowseSession, string, error) { repo, err := s.resolveDatabase(ctx, tool, ref) if err != nil { return nil, "", err } sess, err := s.openStore(ctx, tool, repo) if err != nil { return nil, "", err } at, err := refFor(ctx, sess, tool, ref, named) if err != nil { sess.Close() return nil, "", err } tables, err := sess.Tables(ctx, at) if err != nil { sess.Close() return nil, "", refMiss(err, tool, noSuchRef(ref, at)) } // The fingerprint is beads.Applies and is asked here, per call, because MCP's // tool list is static per server: these tools are advertised for every // database on the instance, so "is this one a tracker" is a question about the // argument rather than about the surface (docs/DESIGN.mcp.md §9). if !beads.Applies(tables) { sess.Close() return nil, "", errors.New(notATracker(ref, at)) } return sess, at, nil } // boardQuery renders the tool's filter as the query the board projection parses, // so that the filtering is beads.Filter's and not a second implementation of it // reading the same rows. // // Status is absent from it deliberately: the projection has no status filter (a // board shows every lane at once), so the category narrowing is applied to the // lanes it answers with, in listIssues. func boardQuery(f issueFilter) url.Values { q := url.Values{} set := func(key, value string) { if value = strings.TrimSpace(value); value != "" { q.Set(key, value) } } set("q", f.Query) set("type", f.Type) set("priority", f.Priority) set("assignee", f.Assignee) set("label", f.Label) if f.Ready { q.Set("ready", "1") } return q } // memoryQuery renders the tool's one filter as the query the memory projection // parses, for boardQuery's reason: the substring rule is beads' and not a second // implementation of it reading the same rows. // // The projection's other two parameters are deliberately not offered. ?key= is // ?q= with an exactness this tool has no use for — a slug is a substring of // itself — and ?sort= is a page's toggle: an answer with a stated order (slug) // is one an agent can sort itself, and every ordering the projection can produce // is derivable from the fields carried here. func memoryQuery(q string) url.Values { values := url.Values{} if q = strings.TrimSpace(q); q != "" { values.Set("q", q) } return values } // parseCategory validates the status filter: empty is no constraint, one of the // three categories is itself, and anything else is refused with the three named. func parseCategory(want string) (string, error) { switch strings.ToLower(strings.TrimSpace(want)) { case "": return "", nil case categoryOpen: return categoryOpen, nil case categoryInProgress: return categoryInProgress, nil case categoryClosed: return categoryClosed, nil default: return "", fmt.Errorf("filter.status must be %q, %q or %q, not %q; "+ "individual status names are per-tracker and are not filterable", categoryOpen, categoryInProgress, categoryClosed, want) } } // laneCategory maps a board lane to the status category it was bucketed from. // Two lanes share "open": an open issue is Stalled when something open blocks it // and Lined Up otherwise, which is a distinction about blockers rather than // about status. // // An unknown slug is not defaulted — see listIssues, which turns it into a // failure rather than a guess. func laneCategory(slug string) (string, bool) { switch slug { case "rolling": return categoryInProgress, true case "past-stand": return categoryClosed, true case "lined-up", "stalled": return categoryOpen, true default: return "", false } } // --- the projections -------------------------------------------------------- func issueOf(i *beads.Issue) issueJSON { labels := i.Labels if labels == nil { labels = []string{} } return issueJSON{ ID: i.ID, Title: i.Title, Status: i.Status, IssueType: i.IssueType, Priority: i.Priority, Lane: i.Lane, Assignee: i.Assignee, CreatedBy: i.CreatedBy, Owner: i.Owner, EstimatedMinutes: i.EstimatedMinutes, ExternalRef: i.ExternalRef, SpecID: i.SpecID, Description: i.Description, Design: i.Design, AcceptanceCriteria: i.AcceptanceCriteria, Notes: i.Notes, CreatedAt: i.CreatedAt, StartedAt: i.StartedAt, UpdatedAt: i.UpdatedAt, ClosedAt: i.ClosedAt, CloseReason: i.CloseReason, Labels: labels, } } func edgesOf(edges []beads.Edge) []edgeJSON { out := make([]edgeJSON, 0, len(edges)) for _, e := range edges { out = append(out, edgeJSON{ IssueID: e.IssueID, Title: e.Title, Type: e.Type, Status: e.Status, Closed: e.Closed, }) } return out } func treeOf(nodes []beads.TreeNode) []treeNodeJSON { out := make([]treeNodeJSON, 0, len(nodes)) for _, n := range nodes { out = append(out, treeNodeJSON{ ID: n.ID, Title: n.Title, Type: n.Type, Status: n.Status, Closed: n.Closed, Depth: n.Depth, }) } return out } func memberOf(c beads.Card) milestoneMemberJSON { return milestoneMemberJSON{ ID: c.ID, Title: c.Title, Type: c.Type, Priority: c.Priority, Assignee: c.Assignee, Category: c.Category, } } func membersOf(cards []beads.Card) []milestoneMemberJSON { out := make([]milestoneMemberJSON, 0, len(cards)) for _, c := range cards { out = append(out, memberOf(c)) } return out } // --- the sentences a caller reads ------------------------------------------- // notATracker is the refusal of docs/DESIGN.mcp.md §9: a database the caller may // read, whose tables are not a beads tracker. It names the generic tools, // because the database is perfectly readable — just not as issues — and it is // pointedly not the masked not-found: the caller is looking straight at this // database. func notATracker(ref databaseRef, at string) string { return fmt.Sprintf("%s is not a beads issue tracker at %q: its tables carry no beads schema "+ "(an \"issues\" table with id and status columns, and a \"dependencies\" table). "+ "It is still a database you can read — list_tables names its tables and read_rows reads them.", ref, at) } // noSuchIssue is an ordinary answer about a tracker the caller can see. func noSuchIssue(ref databaseRef, at, id string) string { return fmt.Sprintf("%s has no issue %q at %q; list_issues names the issues there", ref, id, at) }