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