From e4bc92294ae4926d7aa93d5235db19fcb6d88790 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Thu, 13 Aug 2026 09:03:46 +0300 Subject: [PATCH] mcpsrv: add the beads tools --- mcpsrv/beads.go | 849 +++++++++++++++++++++++++++++++ mcpsrv/beads_test.go | 1096 +++++++++++++++++++++++++++++++++++++++++ mcpsrv/mcpsrv_test.go | 13 +- mcpsrv/read.go | 6 + 4 files changed, 1961 insertions(+), 3 deletions(-) create mode 100644 mcpsrv/beads.go create mode 100644 mcpsrv/beads_test.go diff --git a/mcpsrv/beads.go b/mcpsrv/beads.go new file mode 100644 index 0000000000000000000000000000000000000000..8cc129c79fe8cf8c8b072a033d47ebe4259a3303 --- /dev/null +++ b/mcpsrv/beads.go @@ -0,0 +1,849 @@ +package mcpsrv + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" + + "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 — the fingerprint, the lane bucketing, +// the ready rule, the filters, the dependency walk, the humanised history and +// the milestone rollup 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 these three 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 and list_milestones 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. + +// 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"` +} + +// --- 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 + }) +} + +// --- 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 +} + +// --- 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 +} + +// 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) +} diff --git a/mcpsrv/beads_test.go b/mcpsrv/beads_test.go new file mode 100644 index 0000000000000000000000000000000000000000..4b761ebd8fd37657b8ffaf13a14cd651f1331405 --- /dev/null +++ b/mcpsrv/beads_test.go @@ -0,0 +1,1096 @@ +package mcpsrv_test + +import ( + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "sourcecraft.dev/bigbes/sr-ht-core/auth" + + "sourcecraft.dev/bigbes/sr-ht-dolt/browse" + "sourcecraft.dev/bigbes/sr-ht-dolt/core" +) + +// The beads-aware tools of docs/DESIGN.mcp.md §9.2, driven through the same +// in-process MCP client as the rest of the suite (mcpsrv_test.go carries the +// plumbing, the callers and the generic fakes). +// +// The tracker fixture below is a *board*, not a table dump: it has an epic with +// two subtasks, a blocking chain two edges long, a custom status no heuristic +// could categorise, a template that must stay out of the ready set, two +// milestones and a comment thread. That is what lets these tests assert the +// projection's own answers — the lane bucketing, the ready rule, the rollup +// arithmetic — rather than that something was rendered. +// +// Four properties are worth more than the rest and each has a section below: a +// listing carries no issue body, a database that is not a tracker is refused +// with a sentence that is neither the mask nor a failure, a masked tracker +// answers exactly as a name nobody took, and the filters narrow the same set the +// board does. + +// bodyMarker prefixes every long text in the fixture — descriptions, design +// notes, acceptance criteria, notes and comment bodies — so that "no bodies +// reached the caller" is a question asked of the *serialised payload* rather +// than of the Go struct. A field added to the listing later would carry a marker +// with it and turn this suite red, which is the point: the rule has to survive +// the type changing. +const bodyMarker = "BODYTEXT" + +func body(what string) string { + return bodyMarker + " " + what + " — long prose an agent did not ask for" +} + +// --- the tracker fixture ----------------------------------------------------- + +// issueColumns is the issues table as bd writes it: identity, status, the +// metadata, the four long texts, the timestamp trail and the three flags the +// ready rule reads. +var issueColumns = []string{ + "id", "title", "status", "issue_type", "priority", "assignee", "created_by", "owner", + "estimated_minutes", "external_ref", "spec_id", + "description", "design", "acceptance_criteria", "notes", + "created_at", "started_at", "updated_at", "closed_at", "close_reason", + "is_blocked", "is_template", "ephemeral", +} + +// row builds one row over cols from the cells it names, leaving the rest empty — +// which is what an unset column reads as through the projection anyway. +// +// A key that is not a column panics rather than being ignored: a typo in a +// fixture that silently sets nothing produces a test that passes for no reason. +func row(cols []string, cells map[string]string) []string { + index := map[string]int{} + for i, c := range cols { + index[c] = i + } + out := make([]string, len(cols)) + for name, v := range cells { + i, ok := index[name] + if !ok { + panic("row: no column named " + name) + } + out[i] = v + } + return out +} + +// beadsTable is one fixture table with every column typed as text, which is what +// a bare-store read hands the projection anyway (browse renders cells). +func beadsTable(name string, cols []string, rows [][]string) fakeTable { + info := make([]browse.ColumnInfo, 0, len(cols)) + for _, c := range cols { + info = append(info, browse.ColumnInfo{Name: c, Type: "text", Nullable: true}) + } + return fakeTable{name: name, cols: info, rows: rows} +} + +// trackerTables is the fixture board: +// +// bd-1 epic open P1 alice — parent of bd-2 and bd-8, milestone:m1 +// bd-2 task in_progress P0 bob — subtask of bd-1, milestone:m1, "parser" +// bd-3 task open P2 alice — blocked by bd-4, milestone:m1, all four bodies +// bd-4 task open P3 — blocked by bd-5, which is closed, so ready +// bd-5 bug closed P1 bob — milestone:m1 +// bd-6 milestone open — milestone:m1's own issue +// bd-7 task open — a template: open, unblocked, NOT ready +// bd-8 task "shipped" P1 bob — subtask of bd-1, milestone:m2 +// +// "shipped" is categorised as closed only through custom_statuses — no name +// heuristic reaches it — so the lanes and the rollups below prove the projection +// consulted that table. +func trackerTables() []fakeTable { + issues := [][]string{ + row(issueColumns, map[string]string{ + "id": "bd-1", "title": "the parser epic", "status": "open", "issue_type": "epic", + "priority": "1", "assignee": "alice", "created_at": "2026-01-01 10:00:00", + "description": body("bd-1"), + }), + row(issueColumns, map[string]string{ + "id": "bd-2", "title": "write the parser", "status": "in_progress", "issue_type": "task", + "priority": "0", "assignee": "bob", "created_by": "alice", + "created_at": "2026-01-02 10:00:00", "started_at": "2026-01-03 09:00:00", + "description": body("bd-2"), "notes": body("bd-2 notes"), + }), + row(issueColumns, map[string]string{ + "id": "bd-3", "title": "ship the parser", "status": "open", "issue_type": "task", + "priority": "2", "assignee": "alice", "created_by": "carol", "owner": "alice", + "estimated_minutes": "90", "external_ref": "https://example.org/tracker/3", "spec_id": "SPEC-7", + "description": body("bd-3 description"), "design": body("bd-3 design"), + "acceptance_criteria": body("bd-3 acceptance"), "notes": body("bd-3 notes"), + "created_at": "2026-01-03 10:00:00", "updated_at": "2026-01-09 09:00:00", + }), + row(issueColumns, map[string]string{ + "id": "bd-4", "title": "review the grammar", "status": "open", "issue_type": "task", + "priority": "3", "created_at": "2026-01-04 10:00:00", + }), + row(issueColumns, map[string]string{ + "id": "bd-5", "title": "old lexer bug", "status": "closed", "issue_type": "bug", + "priority": "1", "assignee": "bob", "created_at": "2026-01-05 10:00:00", + "closed_at": "2026-01-05 12:00:00", "close_reason": "fixed while writing bd-2", + }), + row(issueColumns, map[string]string{ + "id": "bd-6", "title": "the m1 milestone", "status": "open", "issue_type": "milestone", + "created_at": "2026-01-06 10:00:00", + }), + row(issueColumns, map[string]string{ + "id": "bd-7", "title": "scaffold", "status": "open", "issue_type": "task", + "created_at": "2026-01-07 10:00:00", "is_template": "1", + }), + row(issueColumns, map[string]string{ + "id": "bd-8", "title": "polish the output", "status": "shipped", "issue_type": "task", + "priority": "1", "assignee": "bob", "created_at": "2026-01-08 10:00:00", + }), + } + + depColumns := []string{"issue_id", "depends_on_issue_id", "type", "created_at", "created_by"} + deps := [][]string{ + {"bd-2", "bd-1", "parent-child", "2026-01-02 11:00:00", "alice"}, + {"bd-8", "bd-1", "parent-child", "2026-01-08 11:00:00", "alice"}, + {"bd-3", "bd-4", "blocks", "2026-01-03 11:00:00", "alice"}, + {"bd-4", "bd-5", "blocks", "2026-01-04 11:00:00", "alice"}, + } + + return []fakeTable{ + beadsTable("issues", issueColumns, issues), + beadsTable("dependencies", depColumns, deps), + beadsTable("labels", []string{"issue_id", "label"}, [][]string{ + {"bd-1", "milestone:m1"}, + {"bd-2", "milestone:m1"}, + {"bd-2", "parser"}, + {"bd-3", "milestone:m1"}, + {"bd-5", "milestone:m1"}, + {"bd-6", "milestone:m1"}, + {"bd-8", "milestone:m2"}, + }), + beadsTable("custom_statuses", []string{"name", "category"}, [][]string{ + {"open", "open"}, + {"in_progress", "in_progress"}, + {"closed", "closed"}, + {"shipped", "closed"}, + }), + beadsTable("comments", []string{"issue_id", "author", "text", "created_at"}, [][]string{ + {"bd-3", "bob", body("bd-3 comment"), "2026-01-09 10:00:00"}, + }), + beadsTable("events", []string{"issue_id", "event_type", "actor", "old_value", "new_value", "comment", "created_at"}, [][]string{ + {"bd-3", "created", "carol", "", "", "", "2026-01-03 10:00:00"}, + {"bd-3", "updated", "alice", "", `{"priority":2}`, "", "2026-01-09 09:00:00"}, + }), + } +} + +// plainTables is a tracker with issues and nothing else: no labels table at all, +// so it carries no milestone and exercises the optional-table degradation the +// projection does. +func plainTables(prefix, title string) []fakeTable { + return []fakeTable{ + beadsTable("issues", issueColumns, [][]string{ + row(issueColumns, map[string]string{ + "id": prefix + "-1", "title": title, "status": "open", "issue_type": "task", + "created_at": "2026-02-01 10:00:00", "description": body(prefix + "-1"), + }), + row(issueColumns, map[string]string{ + "id": prefix + "-2", "title": title + " (done)", "status": "closed", "issue_type": "task", + "created_at": "2026-02-02 10:00:00", + }), + }), + beadsTable("dependencies", []string{"issue_id", "depends_on_issue_id", "type"}, nil), + } +} + +// bulkTables is a tracker larger than beads.Max (2000), which is the only way to +// see the projection's own clip reported. +func bulkTables(n int) []fakeTable { + rows := make([][]string, 0, n) + for i := range n { + rows = append(rows, row(issueColumns, map[string]string{ + "id": "bulk-" + itoa(i), "title": "bulk issue", "status": "open", "issue_type": "task", + })) + } + return []fakeTable{ + beadsTable("issues", issueColumns, rows), + beadsTable("dependencies", []string{"issue_id", "depends_on_issue_id", "type"}, nil), + } +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var b []byte + for n > 0 { + b = append([]byte{byte('0' + n%10)}, b...) + n /= 10 + } + return string(b) +} + +// bulkIssues is the size of the bulk tracker: more than beads.Max, and not a +// multiple of it, so a clip is visible as a number rather than as a round one. +const bulkIssues = 2100 + +// trackerStore is a fixture store of two branches over the tables given. Both +// branches serve the same tables, which is enough for the ref questions these +// tools raise: which ref was read, and what a ref that resolves to neither +// answers. +func trackerStore(tables []fakeTable) *fakeSession { + return &fakeSession{ + branches: []browse.Branch{{Name: "main", Head: "b001"}, {Name: "wip", Head: "b002"}}, + commits: []browse.CommitInfo{ + {Hash: "b001", Author: "alice", Date: headTime, Message: "the tracker"}, + {Hash: "b002", Author: "alice", Date: headTime, Message: "work in progress"}, + }, + tables: tables, + } +} + +// beadsFixtures are the tracker databases this suite adds to the shared fixture +// set: one of every visibility, so the matrix has something to say about each, +// plus the two special shapes (no milestones, larger than the cap). +// +// They are added rather than folded into fixtures() so that the listing suites +// keep asserting about exactly the databases they were written for. +func beadsFixtures() []fixture { + return []fixture{ + {name: "board", visibility: core.VisibilityPublic, session: trackerStore(trackerTables())}, + {name: "backlog", visibility: core.VisibilityUnlisted, session: trackerStore(plainTables("bl", "an unlisted task"))}, + { + name: "roadmap", + visibility: core.VisibilityPrivate, + acl: map[int]core.AccessMode{bobID: core.AccessRO}, + session: trackerStore(plainTables("rm", "SECRETROADMAP the private plan")), + }, + {name: "bulk", visibility: core.VisibilityPublic, session: trackerStore(bulkTables(bulkIssues))}, + } +} + +func beadsFixtureNamed(t *testing.T, name string) fixture { + t.Helper() + for _, f := range beadsFixtures() { + if f.name == name { + return f + } + } + t.Fatalf("no beads fixture named %q", name) + return fixture{} +} + +// beadsFakes builds the shared fakes with the tracker fixtures added to them. +func beadsFakes() (*fakeRepos, *fakeOpener) { + repos, opener := newFakeRepos(), newFakeOpener() + for _, fx := range beadsFixtures() { + id := len(repos.repos) + 1 + repos.repos = append(repos.repos, &core.Repo{ + ID: id, + Name: fx.name, + Description: "the " + fx.name + " tracker", + OwnerID: aliceID, + OwnerName: "alice", + Path: storePath("alice", fx.name), + Visibility: fx.visibility, + }) + for userID, mode := range fx.acl { + if repos.acl[id] == nil { + repos.acl[id] = map[int]core.AccessMode{} + } + repos.acl[id][userID] = mode + } + if fx.session != nil { + opener.sessions[storePath("alice", fx.name)] = fx.session + } + } + return repos, opener +} + +func beadsServer(t *testing.T) *mcp.ClientSession { + t.Helper() + repos, opener := beadsFakes() + return connect(t, newServer(t, repos, opener), nil) +} + +// --- the shapes a client decodes -------------------------------------------- +// +// Spelled out here rather than exported from the package: they are this +// surface's contract, and a test that reused the production structs would pass +// no matter what those structs said. + +type ( + issueCardResult 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 int `json:"blocked_by"` + Blocks int `json:"blocks"` + Ready bool `json:"ready"` + Lane string `json:"lane"` + Category string `json:"category"` + } + + listIssuesResult struct { + Ref string `json:"ref"` + Issues []issueCardResult `json:"issues"` + Total int `json:"total"` + Limit int `json:"limit"` + Truncated bool `json:"truncated"` + TableTruncated bool `json:"table_truncated"` + TableTotal int `json:"table_total"` + } + + issueResult struct { + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + IssueType string `json:"issue_type"` + Priority string `json:"priority"` + 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"` + Description string `json:"description"` + Design string `json:"design"` + AcceptanceCriteria string `json:"acceptance_criteria"` + Notes string `json:"notes"` + 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"` + } + + edgeResult struct { + IssueID string `json:"issue_id"` + Title string `json:"title"` + Type string `json:"type"` + Status string `json:"status"` + Closed bool `json:"closed"` + } + + treeNodeResult 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"` + } + + subtaskResult 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"` + } + + commentResult struct { + Author string `json:"author"` + Text string `json:"text"` + CreatedAt string `json:"created_at"` + } + + activityResult 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"` + } + + getIssueResult struct { + Ref string `json:"ref"` + Issue issueResult `json:"issue"` + IsEpic bool `json:"is_epic"` + DependsOn []edgeResult `json:"depends_on"` + DependedOnBy []edgeResult `json:"depended_on_by"` + DependsTree []treeNodeResult `json:"depends_tree"` + DependentTree []treeNodeResult `json:"dependent_tree"` + Subtasks []subtaskResult `json:"subtasks"` + SubtaskDone int `json:"subtask_done"` + SubtaskTotal int `json:"subtask_total"` + Comments []commentResult `json:"comments"` + History []activityResult `json:"history"` + } + + milestoneMemberResult 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"` + } + + milestoneEpicResult struct { + Issue milestoneMemberResult `json:"issue"` + Done int `json:"done"` + Total int `json:"total"` + Children []milestoneMemberResult `json:"children"` + } + + milestoneResult struct { + Name string `json:"name"` + Label string `json:"label"` + Total int `json:"total"` + Done int `json:"done"` + InProgress int `json:"in_progress"` + Open int `json:"open"` + Heads []milestoneMemberResult `json:"heads"` + Epics []milestoneEpicResult `json:"epics"` + Loose []milestoneMemberResult `json:"loose"` + } + + listMilestonesResult struct { + Ref string `json:"ref"` + Milestones []milestoneResult `json:"milestones"` + Unlabeled int `json:"unlabeled"` + Total int `json:"total"` + } +) + +func listIssues(t *testing.T, s *mcp.ClientSession, a map[string]any) listIssuesResult { + t.Helper() + var out listIssuesResult + decode(t, call(t, s, "list_issues", a), &out) + return out +} + +func getIssue(t *testing.T, s *mcp.ClientSession, a map[string]any) getIssueResult { + t.Helper() + var out getIssueResult + decode(t, call(t, s, "get_issue", a), &out) + return out +} + +func listMilestones(t *testing.T, s *mcp.ClientSession, a map[string]any) listMilestonesResult { + t.Helper() + var out listMilestonesResult + decode(t, call(t, s, "list_milestones", a), &out) + return out +} + +// filter builds the nested filter argument, so a call in a test reads as the one +// constraint it is about. +func filter(kv ...any) map[string]any { + if len(kv)%2 != 0 { + panic("filter: odd key/value list") + } + out := map[string]any{} + for i := 0; i < len(kv); i += 2 { + out[kv[i].(string)] = kv[i+1] + } + return out +} + +func cardIDs(res listIssuesResult) []string { + out := make([]string, 0, len(res.Issues)) + for _, c := range res.Issues { + out = append(out, c.ID) + } + return out +} + +// --- the list carries no bodies --------------------------------------------- + +// The list/detail split of docs/DESIGN.mcp.md §9.2, asserted over the payload +// that actually goes over the wire rather than over the Go struct: a field added +// to the card later would carry a body marker into this string and turn the test +// red, which is the whole reason the fixture marks its long texts. +func TestListIssuesCarriesNoIssueBodies(t *testing.T) { + session := beadsServer(t) + res := call(t, session, "list_issues", args("board")) + require.False(t, res.IsError, "%s", errorText(res)) + + payload := resultJSON(t, res) + assert.NotContains(t, payload, bodyMarker, + "a listing carries identity and metadata; every long text in the fixture is marked and none may appear") + for _, field := range []string{"description", "design", "acceptance_criteria", "notes", "comment"} { + assert.NotContains(t, payload, `"`+field+`"`, + "the card type has no field a body could arrive in, and that is structural") + } + + // The same call through the same tracker with get_issue does carry them, so + // the assertion above is about where bodies live and not about a fixture that + // has none. + detail := getIssue(t, session, args("board", "id", "bd-3")) + assert.Contains(t, detail.Issue.Description, bodyMarker) +} + +// --- what list_issues answers ----------------------------------------------- + +func TestListIssuesAnswersTheBoardsCards(t *testing.T) { + got := listIssues(t, beadsServer(t), args("board")) + + assert.Equal(t, "main", got.Ref, "the default branch, named back") + assert.Equal(t, 200, got.Limit, "the default of docs/DESIGN.mcp.md §9.3") + assert.Equal(t, 8, got.Total) + assert.False(t, got.Truncated) + assert.False(t, got.TableTruncated) + assert.Equal(t, 8, got.TableTotal) + + // The board's own parade order: Rolling, Lined Up, Stalled, Past Stand, and + // within a lane by priority, then age, then id. + assert.Equal(t, []string{"bd-2", "bd-1", "bd-4", "bd-6", "bd-7", "bd-3", "bd-5", "bd-8"}, cardIDs(got)) + + byID := map[string]issueCardResult{} + for _, c := range got.Issues { + byID[c.ID] = c + } + + assert.Equal(t, issueCardResult{ + ID: "bd-3", Title: "ship the parser", Type: "task", Priority: "2", Assignee: "alice", + Labels: []string{"milestone:m1"}, BlockedBy: 1, Blocks: 0, Ready: false, + Lane: "Stalled", Category: "open", + }, byID["bd-3"], "an open issue with an open blocker is Stalled, and the blocker was derived from the edges") + + assert.Equal(t, "Rolling", byID["bd-2"].Lane) + assert.Equal(t, "in_progress", byID["bd-2"].Category) + assert.ElementsMatch(t, []string{"milestone:m1", "parser"}, byID["bd-2"].Labels) + + assert.Equal(t, 2, byID["bd-1"].Blocks, "two subtasks point at the epic") + assert.Equal(t, 0, byID["bd-1"].BlockedBy, "and hierarchy is not a blocker") + + // "shipped" is closed only because custom_statuses says so: no name heuristic + // reaches it, so this card proves the projection consulted that table. + assert.Equal(t, "Past Stand", byID["bd-8"].Lane) + assert.Equal(t, "closed", byID["bd-8"].Category) +} + +// Each filter narrows the listing exactly as the board narrows: the filtering is +// beads.Filter's, and these are the cases that would catch a second +// implementation drifting from it. +func TestListIssuesFiltersNarrowAsTheBoardDoes(t *testing.T) { + session := beadsServer(t) + + for _, tc := range []struct { + name string + f map[string]any + want []string + }{ + {"status open", filter("status", "open"), []string{"bd-1", "bd-3", "bd-4", "bd-6", "bd-7"}}, + {"status in_progress", filter("status", "in_progress"), []string{"bd-2"}}, + {"status closed", filter("status", "closed"), []string{"bd-5", "bd-8"}}, + {"type", filter("type", "bug"), []string{"bd-5"}}, + {"priority", filter("priority", "0"), []string{"bd-2"}}, + {"assignee", filter("assignee", "bob"), []string{"bd-2", "bd-5", "bd-8"}}, + {"label", filter("label", "milestone:m1"), []string{"bd-1", "bd-2", "bd-3", "bd-5", "bd-6"}}, + {"q over the title", filter("q", "parser"), []string{"bd-1", "bd-2", "bd-3"}}, + {"q over the id", filter("q", "bd-7"), []string{"bd-7"}}, + {"q is case-insensitive", filter("q", "PARSER"), []string{"bd-1", "bd-2", "bd-3"}}, + {"two filters at once", filter("assignee", "bob", "status", "closed"), []string{"bd-5", "bd-8"}}, + {"a filter nothing matches", filter("assignee", "nobody"), nil}, + } { + t.Run(tc.name, func(t *testing.T) { + got := listIssues(t, session, args("board", "filter", tc.f)) + assert.ElementsMatch(t, tc.want, cardIDs(got)) + assert.Equal(t, len(tc.want), got.Total, "the total is the matched set, not the board") + }) + } +} + +// The ready filter is bd's ready set — open, unblocked, not a template — and it +// has to agree with the per-card flag: two readings of "ready" on one surface is +// how the board and this tool would start disagreeing. +func TestListIssuesReadyIsTheProjectionsReadySet(t *testing.T) { + session := beadsServer(t) + + ready := listIssues(t, session, args("board", "filter", filter("ready", true))) + assert.ElementsMatch(t, []string{"bd-1", "bd-4", "bd-6"}, cardIDs(ready), + "bd-3 is blocked, bd-7 is a template, bd-2 is in progress and bd-5/bd-8 are closed") + + var flagged []string + for _, c := range listIssues(t, session, args("board")).Issues { + if c.Ready { + flagged = append(flagged, c.ID) + } + } + assert.ElementsMatch(t, cardIDs(ready), flagged, "the filter and the flag are one rule") + + // bd-4 is the case worth naming: it has a dependency, and the dependency is + // closed, so it does not block. + assert.False(t, listIssues(t, session, args("board")).Issues[0].Ready, "bd-2 is in progress") + for _, c := range ready.Issues { + if c.ID == "bd-4" { + assert.Equal(t, 1, c.BlockedBy, "a closed blocker is still a dependency") + return + } + } + t.Fatal("bd-4 was not in the ready set") +} + +// An unrecognised category is refused rather than matched against nothing: an +// empty board would read as "no work is under way", which is a false statement +// about the tracker. +func TestListIssuesRefusesAnUnknownStatus(t *testing.T) { + res := call(t, beadsServer(t), "list_issues", args("board", "filter", filter("status", "in-progress"))) + require.True(t, res.IsError) + + text := errorText(res) + assert.Contains(t, text, "in-progress") + assert.Contains(t, text, "in_progress", "and the three categories are named") + assert.Contains(t, text, "closed") +} + +// The cap of docs/DESIGN.mcp.md §9.3 is applied after filtering and *stated*: the +// answer carries the limit really used and the number of matches it stopped +// short of. +func TestListIssuesCapsTheLimitAndSaysSo(t *testing.T) { + session := beadsServer(t) + + t.Run("a limit above the cap is answered at the cap", func(t *testing.T) { + got := listIssues(t, session, args("board", "limit", 5000)) + assert.Equal(t, 500, got.Limit, "the cap, reported rather than silently applied") + assert.Len(t, got.Issues, 8, "the tracker is smaller than the cap") + assert.False(t, got.Truncated) + }) + + t.Run("a limit below the matches clips and says so", func(t *testing.T) { + got := listIssues(t, session, args("board", "limit", 3)) + assert.Equal(t, 3, got.Limit) + require.Len(t, got.Issues, 3) + assert.Equal(t, 8, got.Total, "the honest denominator is every match") + assert.True(t, got.Truncated) + }) + + t.Run("applied after filtering", func(t *testing.T) { + got := listIssues(t, session, args("board", "filter", filter("status", "closed"), "limit", 1)) + assert.Equal(t, 2, got.Total, "two issues matched") + assert.Len(t, got.Issues, 1) + assert.True(t, got.Truncated) + }) + + t.Run("a non-positive limit is refused", func(t *testing.T) { + for _, limit := range []int{0, -1} { + res := call(t, session, "list_issues", args("board", "limit", limit)) + require.True(t, res.IsError, "limit %d", limit) + assert.Contains(t, errorText(res), "limit must be a positive number of issues") + } + }) +} + +// The projection reads at most beads.Max rows per table, and a board computed +// over a clipped table is a count a caller cannot check any other way. It is a +// separate flag from the limit's truncation because it is a different fact. +func TestListIssuesReportsTheProjectionsOwnClip(t *testing.T) { + got := listIssues(t, beadsServer(t), args("bulk", "limit", 10)) + + assert.True(t, got.TableTruncated, "the tracker has more issues than the projection reads in one pass") + assert.Equal(t, bulkIssues, got.TableTotal, "and the true row count is reported beside it") + assert.Equal(t, 2000, got.Total, "the board was computed over the rows that were read") + assert.Len(t, got.Issues, 10) + assert.True(t, got.Truncated, "the limit clipped the list too, and the two are told apart") +} + +// An omitted ref is the tracker's default branch and the answer names it; a +// named one is read as given. +func TestABeadsToolDefaultsTheRef(t *testing.T) { + session := beadsServer(t) + + assert.Equal(t, "main", listIssues(t, session, args("board")).Ref) + assert.Equal(t, "wip", listIssues(t, session, args("board", "ref", "wip")).Ref) + assert.Equal(t, "wip", getIssue(t, session, args("board", "id", "bd-1", "ref", "wip")).Ref) + assert.Equal(t, "wip", listMilestones(t, session, args("board", "ref", "wip")).Ref) +} + +// --- what get_issue answers -------------------------------------------------- + +func TestGetIssueCarriesEveryModelledField(t *testing.T) { + got := getIssue(t, beadsServer(t), args("board", "id", "bd-3")) + + assert.Equal(t, issueResult{ + ID: "bd-3", Title: "ship the parser", Status: "open", IssueType: "task", Priority: "2", + Lane: "Lined Up", Assignee: "alice", CreatedBy: "carol", Owner: "alice", + EstimatedMinutes: "90", ExternalRef: "https://example.org/tracker/3", SpecID: "SPEC-7", + Description: body("bd-3 description"), + Design: body("bd-3 design"), + AcceptanceCriteria: body("bd-3 acceptance"), + Notes: body("bd-3 notes"), + CreatedAt: "2026-01-03 10:00:00", UpdatedAt: "2026-01-09 09:00:00", + Labels: []string{"milestone:m1"}, + }, got.Issue) + assert.False(t, got.IsEpic) + assert.Empty(t, got.Subtasks) +} + +// Both directions, and they are not the same list: what an issue waits on and +// what waits on it are answered separately, each with the far end resolved to a +// title and a status. +func TestGetIssueAnswersBothDependencyDirections(t *testing.T) { + got := getIssue(t, beadsServer(t), args("board", "id", "bd-4")) + + assert.Equal(t, []edgeResult{ + {IssueID: "bd-5", Title: "old lexer bug", Type: "blocks", Status: "closed", Closed: true}, + }, got.DependsOn, "bd-4 waits on a bug that is already closed") + assert.Equal(t, []edgeResult{ + {IssueID: "bd-3", Title: "ship the parser", Type: "blocks", Status: "open", Closed: false}, + }, got.DependedOnBy) +} + +// The transitive tree reaches past the direct edges, which is the only reason it +// is carried at all: bd-3 waits on bd-4, and bd-4 waits on bd-5. +func TestGetIssueFlattensTheTransitiveTree(t *testing.T) { + got := getIssue(t, beadsServer(t), args("board", "id", "bd-3")) + + assert.Equal(t, []treeNodeResult{ + {ID: "bd-4", Title: "review the grammar", Type: "blocks", Status: "open", Depth: 0}, + {ID: "bd-5", Title: "old lexer bug", Type: "blocks", Status: "closed", Closed: true, Depth: 1}, + }, got.DependsTree) + assert.Empty(t, got.DependentTree, "nothing depends on bd-3, transitively or otherwise") +} + +func TestGetIssueOfAnEpicRollsUpItsSubtasks(t *testing.T) { + got := getIssue(t, beadsServer(t), args("board", "id", "bd-1")) + + assert.True(t, got.IsEpic) + assert.Equal(t, 2, got.SubtaskTotal) + assert.Equal(t, 1, got.SubtaskDone, "bd-8 is \"shipped\", which custom_statuses categorises as closed") + assert.Equal(t, []subtaskResult{ + {ID: "bd-2", Title: "write the parser", Status: "in_progress", Category: "in_progress", Priority: "0", Assignee: "bob"}, + {ID: "bd-8", Title: "polish the output", Status: "shipped", Category: "closed", Priority: "1", Assignee: "bob"}, + }, got.Subtasks, "open work leads and closed subtasks sink") + + ids := make([]string, 0, len(got.DependedOnBy)) + for _, e := range got.DependedOnBy { + ids = append(ids, e.IssueID) + } + assert.ElementsMatch(t, []string{"bd-2", "bd-8"}, ids, "the subtasks are edges too") +} + +// The history is the comments and the audit trail merged and time-ordered, with +// the dependency links beads records on the edge row rather than as events. +func TestGetIssueMergesTheHistory(t *testing.T) { + got := getIssue(t, beadsServer(t), args("board", "id", "bd-3")) + + require.Len(t, got.Comments, 1) + assert.Equal(t, "bob", got.Comments[0].Author) + assert.Contains(t, got.Comments[0].Text, bodyMarker) + + var summaries []string + for _, a := range got.History { + summaries = append(summaries, a.Summary) + } + assert.Equal(t, []string{ + "created the issue", + "added dependency on bd-4", + "updated priority to 2", + "commented", + }, summaries, "oldest first, and humanised by the projection") +} + +// An id the tracker does not carry is an ordinary answer about a database the +// caller is looking straight at — it names the id and the database, and it is +// not the masked not-found. +func TestGetIssueOfAnUnknownIDIsAPlainMiss(t *testing.T) { + res := call(t, beadsServer(t), "get_issue", args("board", "id", "bd-999")) + require.True(t, res.IsError) + + text := errorText(res) + assert.Contains(t, text, "bd-999") + assert.Contains(t, text, "~alice/board") + assert.Contains(t, text, "main", "and says where it looked") + assert.NotContains(t, text, "no database", "this is not the masked not-found") +} + +func TestGetIssueNeedsAnID(t *testing.T) { + res := call(t, beadsServer(t), "get_issue", args("board", "id", " ")) + require.True(t, res.IsError) + assert.Contains(t, errorText(res), "issue") +} + +// --- what list_milestones answers -------------------------------------------- + +func TestListMilestonesRollsUpTheMembers(t *testing.T) { + got := listMilestones(t, beadsServer(t), args("board")) + + assert.Equal(t, "main", got.Ref) + assert.Equal(t, 8, got.Total, "every issue read") + assert.Equal(t, 2, got.Unlabeled, "bd-4 and bd-7 carry no milestone label") + require.Len(t, got.Milestones, 2) + + m1 := got.Milestones[0] + assert.Equal(t, "m1", m1.Name) + assert.Equal(t, "milestone:m1", m1.Label) + assert.Equal(t, 5, m1.Total) + assert.Equal(t, 1, m1.Done, "bd-5") + assert.Equal(t, 1, m1.InProgress, "bd-2") + assert.Equal(t, 3, m1.Open, "bd-1, bd-3 and bd-6") + assert.Equal(t, m1.Total, m1.Done+m1.InProgress+m1.Open, "the three partition the total") + + // The shallow hierarchy: the milestone's own issue, then its epics with the + // members nested under them, then the leftovers. Every member appears once. + require.Len(t, m1.Heads, 1) + assert.Equal(t, "bd-6", m1.Heads[0].ID) + require.Len(t, m1.Epics, 1) + assert.Equal(t, "bd-1", m1.Epics[0].Issue.ID) + assert.Equal(t, 1, m1.Epics[0].Total, "only bd-2 is both a child of bd-1 and a member of m1") + assert.Equal(t, 0, m1.Epics[0].Done) + require.Len(t, m1.Epics[0].Children, 1) + assert.Equal(t, "bd-2", m1.Epics[0].Children[0].ID) + assert.Equal(t, "in_progress", m1.Epics[0].Children[0].Category) + assert.Equal(t, []string{"bd-3", "bd-5"}, memberIDs(m1.Loose), "open work leads, closed sinks") + + assert.Equal(t, 1+1+len(m1.Epics[0].Children)+len(m1.Loose), m1.Total, + "heads + epics + their children + loose is the whole membership") + + m2 := got.Milestones[1] + assert.Equal(t, "m2", m2.Name) + assert.Equal(t, 1, m2.Total) + assert.Equal(t, 1, m2.Done, "bd-8 is \"shipped\"") + assert.Equal(t, []string{"bd-8"}, memberIDs(m2.Loose), + "its epic is not a member of m2, so it does not nest") +} + +// A tracker that uses no milestone labels answers an empty list — an answer, not +// an error, and not an empty *tracker* either: every issue is reported as +// unlabeled. +func TestListMilestonesOnATrackerWithNoMilestones(t *testing.T) { + repos, opener := beadsFakes() + session := connect(t, newServer(t, repos, opener), bob()) + + res := call(t, session, "list_milestones", args("backlog")) + require.False(t, res.IsError, "%s", errorText(res)) + + var out listMilestonesResult + decode(t, res, &out) + assert.Empty(t, out.Milestones) + assert.Equal(t, 2, out.Total) + assert.Equal(t, 2, out.Unlabeled) +} + +// A milestone member carries only what the rollup computes. The dependency +// counts and the ready flag are absent rather than zero, because a 0 and a false +// an agent cannot check are four lies per member. +func TestAMilestoneMemberCarriesNoUncomputedFields(t *testing.T) { + payload := resultJSON(t, call(t, beadsServer(t), "list_milestones", args("board"))) + + assert.NotContains(t, payload, "blocked_by") + assert.NotContains(t, payload, `"ready"`) + assert.NotContains(t, payload, bodyMarker, "and no bodies either") +} + +func memberIDs(members []milestoneMemberResult) []string { + out := make([]string, 0, len(members)) + for _, m := range members { + out = append(out, m.ID) + } + return out +} + +// --- a database that is not a tracker ---------------------------------------- + +// beadsCall is one tool and the arguments it needs for a database, so a property +// worth holding for the whole chapter is written once and asserted for each. +type beadsCall struct { + name string + args func(db string) map[string]any +} + +func beadsTools() []beadsCall { + return []beadsCall{ + {"list_issues", func(db string) map[string]any { return args(db) }}, + {"get_issue", func(db string) map[string]any { return args(db, "id", "bd-1") }}, + {"list_milestones", func(db string) map[string]any { return args(db) }}, + } +} + +// MCP's tool list is static per server, so these tools are advertised for every +// database on the instance. One whose tables are not a tracker gets an +// explanatory refusal that names the generic tools — and it is three-way +// distinguishable: not the masked not-found (that database exists and is +// readable), and not a protocol error (the call was understood and answered). +func TestANonBeadsDatabaseIsRefusedWithTheWayToReadItAnyway(t *testing.T) { + repos, opener := beadsFakes() + server := newServer(t, repos, opener) + + for _, tool := range beadsTools() { + t.Run(tool.name, func(t *testing.T) { + session := connect(t, server, nil) + + res, err := session.CallTool(t.Context(), &mcp.CallToolParams{ + Name: tool.name, + Arguments: tool.args("notes"), + }) + require.NoError(t, err, "a database that is not a tracker is an answer, not a protocol failure") + require.True(t, res.IsError) + + text := errorText(res) + assert.Contains(t, text, "~alice/notes") + assert.Contains(t, text, "not a beads issue tracker") + assert.Contains(t, text, "list_tables", "and names the way to read it anyway") + assert.Contains(t, text, "read_rows") + assert.NotContains(t, text, "no database", "the database exists and this caller may read it") + + // And it is not what a masked database answers, which is the distinction + // the whole sentence exists to keep. + masked := call(t, connect(t, server, carol()), tool.name, tool.args("secrets")) + require.True(t, masked.IsError) + assert.NotEqual(t, errorText(masked), text) + }) + } +} + +// A ref that resolves to neither a branch nor a commit is answered about the +// database, not about the fingerprint: the tracker was never read, so calling it +// "not a beads tracker" would be a claim this service did not check. +func TestABeadsToolReportsAnUnresolvableRef(t *testing.T) { + repos, opener := beadsFakes() + server := newServer(t, repos, opener) + + for _, tool := range beadsTools() { + t.Run(tool.name, func(t *testing.T) { + a := tool.args("board") + a["ref"] = "nope" + res := call(t, connect(t, server, nil), tool.name, a) + require.True(t, res.IsError) + + text := errorText(res) + assert.Contains(t, text, "nope") + assert.Contains(t, text, "~alice/board") + assert.NotContains(t, text, "no database") + assert.NotContains(t, text, "not a beads issue tracker") + }) + } +} + +// --- the visibility matrix --------------------------------------------------- + +// Every beads tool, every visibility, every principal: either the tool answers, +// or the database is reported as not existing. There is no third outcome and in +// particular no "forbidden" — a refusal of its own shape is exactly the +// distinction the masked not-found exists to erase (docs/DESIGN.mcp.md §4.3). +// +// The masked answer is compared against the answer for a database that genuinely +// does not exist, with the name substituted: the two must differ by nothing but +// the name the caller itself supplied. +func TestTheBeadsVisibilityMatrix(t *testing.T) { + repos, opener := beadsFakes() + server := newServer(t, repos, opener) + + callers := []struct { + name string + ac *auth.AuthContext + }{ + {"anonymous", nil}, + {"a stranger", carol()}, + {"a grantee", bob()}, + {"the owner", alice()}, + } + + // The id every tracker fixture carries, so get_issue can be asked about each + // of them without the answer depending on which one it is. + ids := map[string]string{"board": "bd-1", "backlog": "bl-1", "roadmap": "rm-1"} + + for _, tool := range beadsTools() { + for _, db := range []string{"board", "backlog", "roadmap"} { + for _, who := range callers { + t.Run(tool.name+"/"+db+"/"+who.name, func(t *testing.T) { + session := connect(t, server, who.ac) + + a := tool.args(db) + if _, ok := a["id"]; ok { + a["id"] = ids[db] + } + res := call(t, session, tool.name, a) + + if readable(beadsFixtureNamed(t, db), coreCaller(who.ac)) { + assert.False(t, res.IsError, "%s", errorText(res)) + return + } + + require.True(t, res.IsError, "a database this caller may not read must not answer") + + absent := tool.args("nosuch") + if _, ok := absent["id"]; ok { + absent["id"] = ids[db] + } + missing := call(t, session, tool.name, absent) + require.True(t, missing.IsError) + + assert.Equal(t, + strings.Replace(errorText(missing), "nosuch", db, 1), + errorText(res), + "a masked database answers exactly as one that does not exist") + }) + } + } + } +} + +// Nothing about a tracker the caller may not read leaks through the refusal — +// not an issue id, not a title, not a body. The database's own name is in the +// sentence because the caller put it there. +func TestNothingAboutAMaskedTrackerLeaks(t *testing.T) { + repos, opener := beadsFakes() + server := newServer(t, repos, opener) + + for _, tool := range beadsTools() { + t.Run(tool.name, func(t *testing.T) { + a := tool.args("roadmap") + if _, ok := a["id"]; ok { + a["id"] = "rm-1" + } + body := resultJSON(t, call(t, connect(t, server, carol()), tool.name, a)) + + assert.NotContains(t, body, "SECRETROADMAP", "not a title") + assert.NotContains(t, body, "rm-2", "not an id it did not ask with") + assert.NotContains(t, body, bodyMarker, "and certainly not a body") + }) + } +} + +// A grantee reads the private tracker whole: the matrix proves the tools answer, +// and this proves they answer with its actual contents rather than an empty +// shell. +func TestAGranteeReadsAPrivateTracker(t *testing.T) { + repos, opener := beadsFakes() + session := connect(t, newServer(t, repos, opener), bob()) + + got := listIssues(t, session, args("roadmap")) + assert.Equal(t, 2, got.Total) + assert.Equal(t, []string{"rm-1", "rm-2"}, cardIDs(got)) + + detail := getIssue(t, session, args("roadmap", "id", "rm-1")) + assert.Equal(t, "SECRETROADMAP the private plan", detail.Issue.Title) +} + +// --- sessions ---------------------------------------------------------------- + +// One session per call, closed by the handler that opened it — on the refusal +// path too, which is the one a helper is most likely to leak. +func TestEveryBeadsToolClosesTheSessionItOpened(t *testing.T) { + for _, tool := range beadsTools() { + t.Run(tool.name, func(t *testing.T) { + t.Run("an answer", func(t *testing.T) { + repos, opener := beadsFakes() + session := connect(t, newServer(t, repos, opener), nil) + + call(t, session, tool.name, tool.args("board")) + + assert.Equal(t, []string{storePath("alice", "board")}, opener.opened, + "exactly the one database the call named") + assert.Equal(t, 1, opener.sessions[storePath("alice", "board")].closes) + }) + + t.Run("a database that is not a tracker", func(t *testing.T) { + repos, opener := beadsFakes() + session := connect(t, newServer(t, repos, opener), nil) + + call(t, session, tool.name, tool.args("notes")) + + assert.Equal(t, 1, opener.sessions[storePath("alice", "notes")].closes, + "the refusal path closes what it opened") + }) + }) + } +} diff --git a/mcpsrv/mcpsrv_test.go b/mcpsrv/mcpsrv_test.go index 3141816ad22f9274eeec1746f236be3555cc2fd7..799b4e94589ad9e9427a5c5c47774b99a2506637 100644 --- a/mcpsrv/mcpsrv_test.go +++ b/mcpsrv/mcpsrv_test.go @@ -766,9 +766,13 @@ func TestServerAdvertisesTheToolsOfTheseChapters(t *testing.T) { assert.True(t, tool.Annotations.ReadOnlyHint, "%s", tool.Name) require.NotNil(t, tool.InputSchema, "%s: the schema is derived from the Go struct", tool.Name) } - // The generic surface of docs/DESIGN.mcp.md §9.1, whole. The beads-aware - // tools of §9.2 arrive with their own commit, and this list is where their - // absence — or an accidental extra — is visible. + // The generic surface of docs/DESIGN.mcp.md §9.1 and the beads-aware tools of + // §9.2, whole. This list is where an accidental extra — or a tool that + // quietly stopped being registered — is visible. + // + // §9.2's list_memories and ready_work are deliberately absent: the memory + // projection and the cross-database aggregation arrive with their own + // commits (§11, phases 5 and 7). assert.ElementsMatch(t, []string{ "list_databases", "list_branches", @@ -776,6 +780,9 @@ func TestServerAdvertisesTheToolsOfTheseChapters(t *testing.T) { "read_rows", "get_commit_log", "get_commit_diff", + "list_issues", + "get_issue", + "list_milestones", }, got) } diff --git a/mcpsrv/read.go b/mcpsrv/read.go index eafb1de8037a67869698ab4091895287f91e364e..2d3ed5ba82730fe75a731667fb84f9efe8f30183 100644 --- a/mcpsrv/read.go +++ b/mcpsrv/read.go @@ -139,6 +139,12 @@ func (s *Server) register() { // The generic tools of docs/DESIGN.mcp.md §9.1, over the browse seam. s.registerBrowseTools() + // The beads-aware tools of docs/DESIGN.mcp.md §9.2, over the beads + // projection. They are advertised for every database on this instance — MCP's + // tool list is static per server — and refuse per database, which is why + // registering them is unconditional here. + s.registerBeadsTools() + mcp.AddTool(s.mcp, &mcp.Tool{ Name: "list_databases", Annotations: readOnlyTool,