@@ 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:<name>" 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:<name>\" 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)
+}
@@ 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")
+ })
+ })
+ }
+}