~bigbes/sr-ht-dolt

e4bc92294ae4926d7aa93d5235db19fcb6d88790 — Eugene Blikh 5 days ago 0190aab
mcpsrv: add the beads tools
4 files changed, 1961 insertions(+), 3 deletions(-)

A mcpsrv/beads.go
A mcpsrv/beads_test.go
M mcpsrv/mcpsrv_test.go
M mcpsrv/read.go
A mcpsrv/beads.go => mcpsrv/beads.go +849 -0
@@ 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)
}

A mcpsrv/beads_test.go => mcpsrv/beads_test.go +1096 -0
@@ 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")
			})
		})
	}
}

M mcpsrv/mcpsrv_test.go => mcpsrv/mcpsrv_test.go +10 -3
@@ 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)
}


M mcpsrv/read.go => mcpsrv/read.go +6 -0
@@ 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,