coverage~bigbes/sr-ht-dolt3523280cmcpsrv/beads.go

Coverage
94.9% 167/176 statements
Δ
+0.0
Blob
81bfebc
1 package mcpsrv
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "net/url"
8 "strings"
9 "time"
10
11 "github.com/modelcontextprotocol/go-sdk/mcp"
12
13 "sourcecraft.dev/bigbes/sr-ht-dolt/beads"
14 "sourcecraft.dev/bigbes/sr-ht-dolt/core"
15 )
16
17 // The beads-aware tools of docs/DESIGN.mcp.md §9.2: a hosted database that
18 // carries the beads (bd) issue schema, read as issues rather than as tables.
19 //
20 // They add three rules to the ones browse.go states for the whole surface:
21 //
22 // - Nothing here reads the schema. Every answer below is a projection of
23 // beads.Build / beads.BuildMilestones / beads.BuildMemories — the
24 // fingerprint, the lane bucketing, the ready rule, the filters, the
25 // dependency walk, the humanised history, the milestone rollup and the
26 // memory revision walk are the ones the web board renders, and they are
27 // shared on purpose (docs/DESIGN.mcp.md §2: no second reading of any schema).
28 // A question this package could answer only by re-reading the tables is a
29 // question it does not answer.
30 // - The list/detail split is structural, not a habit. listIssuesOutput carries
31 // issueCardJSON, which has no field a description, a design note, an
32 // acceptance criterion or a comment could arrive in; the bodies are
33 // get_issue's, one issue at a time. A board of 78 issues carrying every long
34 // text is the agent's context window spent on text it did not ask for, which
35 // is exactly what this surface exists to save.
36 // - A database that is not a tracker is refused *per call*. MCP's tool list is
37 // static per server, so all of them are advertised for every database on the
38 // instance; one whose tables do not carry the fingerprint gets a sentence
39 // naming the generic tools as the way to read it anyway. That refusal is an
40 // ordinary answer about a database the caller can see — never the masked
41 // not-found of a database it may not.
42 //
43 // What the projection reads and what it therefore cannot say: it reads up to
44 // beads.Max (2000) rows per table in one pass, and every tool here whose answer
45 // is computed over such a read reports the clip in the same two fields —
46 // table_truncated and table_total, the vocabulary list_issues established. A
47 // board, a milestone rollup or an issue read out of a clipped table describes a
48 // prefix of the tracker, and a caller has no other way to check that.
49 //
50 // get_issue carries one further consequence of the same fact: an id that is not
51 // among the rows read is not thereby known to be absent. On a complete read the
52 // miss is the ordinary one ("no such issue"); on a clipped read the answer says
53 // the id was not in the first beads.Max rows and names the tracker's true total,
54 // because "there is no such issue" is a claim this projection cannot make there.
55 // Both are error results, and the clipped one also carries the structured
56 // payload — so the two are told apart by a field and not only by a sentence.
57 //
58 // list_memories has a clip of an entirely different kind and does report it —
59 // the revision walk's, which is about the history rather than about a table (see
60 // memoryJSON.Revision).
61
62 // The caps of docs/DESIGN.mcp.md §9.3 for the issue listing. They are applied
63 // *after* filtering — the limit clips a result set, not a table read — and, like
64 // every cap on this surface, the applied one is reported rather than assumed.
65 const (
66 defaultIssueLimit = 200
67 maxIssueLimit = 500
68 )
69
70 // The three status categories the projection buckets a status into
71 // (beads.statusCategory). They are this surface's filter vocabulary because they
72 // are the only status grouping shared by every tracker: the status *names* are
73 // per-database (a tracker may define its own in custom_statuses), so filtering on
74 // one would be filtering on a string this service cannot enumerate.
75 const (
76 categoryOpen = "open"
77 categoryInProgress = "in_progress"
78 categoryClosed = "closed"
79 )
80
81 // --- the shapes a caller decodes -------------------------------------------
82
83 // issueCardJSON is one issue as a *listing* carries it: identity, metadata, and
84 // the two counts and two flags a triage decision is made on.
85 //
86 // It carries no long text and it has no field one could arrive in — no
87 // description, no design, no acceptance criteria, no notes, no comment. That is
88 // the list/detail split of docs/DESIGN.mcp.md §9.2 made structural rather than
89 // remembered: a board is identity and metadata, and the bodies are get_issue's,
90 // one issue at a time. Adding such a field here would silently spend the context
91 // window of every agent that lists a tracker, so this type is the place the rule
92 // is enforced and the test asserts it over the serialised payload.
93 //
94 // Title is the exception that proves it: it is the issue's name, not its text.
95 type issueCardJSON struct {
96 ID string `json:"id"`
97 Title string `json:"title"`
98
99 // Type is the issue_type as stored ("task", "bug", "epic", "milestone", …),
100 // and Priority is the raw priority ("0".."3", or "" when unset) rather than a
101 // rendered label: an agent sorts on the number.
102 Type string `json:"type"`
103 Priority string `json:"priority"`
104 Assignee string `json:"assignee"`
105 Labels []string `json:"labels"`
106
107 // BlockedBy is how many dependencies this issue has, and Blocks how many
108 // point at it. They are counts and not lists: the edges themselves are
109 // get_issue's, with the titles and statuses that make them readable.
110 BlockedBy int `json:"blocked_by"`
111 Blocks int `json:"blocks"`
112
113 // Ready is bd's ready set: open, unblocked, and not a template or an
114 // ephemeral scaffold. It is the projection's rule, the same one the board
115 // paints and `bd ready` prints.
116 Ready bool `json:"ready"`
117
118 // Lane is where the board places this issue — "Rolling", "Lined Up",
119 // "Stalled" or "Past Stand" — which carries one thing Category does not: an
120 // open issue with an open blocker is Stalled rather than Lined Up.
121 Lane string `json:"lane"`
122
123 // Category is the status category the lane was derived from: open,
124 // in_progress or closed. It is what the status filter matches.
125 Category string `json:"category"`
126 }
127
128 // issueFilter is the board's own filter model (beads.Filter) as tool arguments.
129 // Every field is an exact match except q, and an unset field is no constraint.
130 type issueFilter struct {
131 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."`
132 Type string `json:"type,omitempty" jsonschema:"an exact issue type, e.g. \"task\", \"bug\", \"epic\", \"milestone\""`
133 Priority string `json:"priority,omitempty" jsonschema:"an exact priority as stored: \"0\" (highest) through \"3\""`
134 Assignee string `json:"assignee,omitempty" jsonschema:"an exact assignee"`
135 Label string `json:"label,omitempty" jsonschema:"a label the issue must carry, e.g. \"milestone:m3\""`
136 Query string `json:"q,omitempty" jsonschema:"a case-insensitive substring of the issue's id or title; it does not search bodies"`
137 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."`
138 }
139
140 type listIssuesInput struct {
141 databaseRef
142 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"`
143 Filter issueFilter `json:"filter,omitempty" jsonschema:"narrows the listing; every field is optional and an omitted one is no constraint"`
144 Limit *int `json:"limit,omitempty" jsonschema:"how many issues to return, at most 500; defaults to 200. It is applied after filtering."`
145 }
146
147 type listIssuesOutput struct {
148 // Ref is the ref actually read, which is the default branch when the call
149 // named none.
150 Ref string `json:"ref"`
151
152 // Issues are in the board's own parade order: Rolling, then Lined Up, then
153 // Stalled, then Past Stand, and within each by priority, then age, then id.
154 Issues []issueCardJSON `json:"issues"`
155
156 // Total is how many issues matched the filter, before Limit clipped them —
157 // the honest denominator of the list above.
158 Total int `json:"total"`
159
160 // Limit is the limit that was applied, which is not always the one asked
161 // for: a request above the cap is answered at the cap.
162 Limit int `json:"limit"`
163
164 // Truncated reports that matches were left behind by Limit. Narrow the
165 // filter or raise the limit; this tool does not page, because a filtered
166 // board is meant to be small.
167 Truncated bool `json:"truncated"`
168
169 // TableTruncated reports the *other* clip, and it is a separate flag because
170 // it is a different fact: the projection reads at most 2000 rows of a table
171 // in one pass (docs/DESIGN.mcp.md §9.3), so on a tracker larger than that the
172 // board above — and every count on it — was computed over the first 2000
173 // issues rather than over all of them. Nothing here pages past it; read_rows
174 // does, if the whole table is really wanted.
175 TableTruncated bool `json:"table_truncated"`
176
177 // TableTotal is the number of rows in the issues table at this ref, which is
178 // what makes TableTruncated checkable rather than a bare warning.
179 TableTotal int `json:"table_total"`
180
181 // Clipped names every table this listing drew from that came back clipped —
182 // one entry per table, in read order, each with the rows read against the
183 // rows that exist and what that specific clip costs this listing.
184 //
185 // It exists because TableTruncated cannot say this: that flag is paired with
186 // Total and Truncated above, so it can only mean the issues/dependencies read
187 // that decides them. This listing also draws label pills and its whole label
188 // filter from a labels table, and every card's lane from custom_statuses, and
189 // a clip in either degrades the listing without moving Total or Truncated by
190 // one. Clipped is where those are named instead. A complete read carries an
191 // empty list, never a null one.
192 Clipped []clippedTableJSON `json:"clipped"`
193 }
194
195 // clippedTableJSON is one table a listing read that came back clipped: its name,
196 // how many rows were read against how many exist, and the one line saying what
197 // this listing lost by the rest. It carries beads.ClippedTable across the wire
198 // unchanged, under the snake_case vocabulary this surface already uses.
199 type clippedTableJSON struct {
200 Table string `json:"table"`
201 Shown int `json:"shown"`
202 Total int `json:"total"`
203 Effect string `json:"effect"`
204 }
205
206 // clippedTablesJSON carries a projection's per-table clip list into the wire
207 // shape, table by table. A nil or empty input answers an empty slice rather than
208 // a null one, so "nothing was clipped" and "the field is unset" are never the
209 // same JSON value.
210 39 func clippedTablesJSON(clipped []beads.ClippedTable) []clippedTableJSON {
211 39 out := make([]clippedTableJSON, 0, len(clipped))
212 39 for _, c := range clipped {
213 3 out = append(out, clippedTableJSON{
214 3 Table: c.Table,
215 3 Shown: c.Shown,
216 3 Total: c.Total,
217 3 Effect: c.Effect,
218 3 })
219 3 }
220 39 return out
221 }
222
223 type getIssueInput struct {
224 databaseRef
225 ID string `json:"id" jsonschema:"the issue id, as list_issues reports it (e.g. \"bd-42\")"`
226 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"`
227 }
228
229 // issueJSON is the whole issue: every field the projection models, bodies
230 // included. This is the detail half of the split issueCardJSON is the list half
231 // of — one issue at a time, because that is what makes the long texts
232 // affordable.
233 type issueJSON struct {
234 ID string `json:"id"`
235 Title string `json:"title"`
236 Status string `json:"status"`
237 IssueType string `json:"issue_type"`
238 Priority string `json:"priority"`
239
240 // Lane is the board lane this issue's status category maps to. It is the
241 // detail pane's lane and is derived from the status alone, so an open issue
242 // with an open blocker reads "Lined Up" here while list_issues places it in
243 // "Stalled" — the blocked signal is in DependsOn, which this answer carries
244 // in full.
245 Lane string `json:"lane"`
246
247 Assignee string `json:"assignee"`
248 CreatedBy string `json:"created_by"`
249 Owner string `json:"owner"`
250
251 EstimatedMinutes string `json:"estimated_minutes"`
252 ExternalRef string `json:"external_ref"`
253 SpecID string `json:"spec_id"`
254
255 // The four long texts bd carries for an issue. They are why get_issue exists
256 // and why no listing on this surface has them.
257 Description string `json:"description"`
258 Design string `json:"design"`
259 AcceptanceCriteria string `json:"acceptance_criteria"`
260 Notes string `json:"notes"`
261
262 // The timestamps as stored ("YYYY-MM-DD HH:MM:SS"), unparsed: this surface
263 // reads a bare store without a SQL engine, so what it has is the stored
264 // string, and re-typing it as a time would be a claim about a timezone
265 // nobody recorded.
266 CreatedAt string `json:"created_at"`
267 StartedAt string `json:"started_at"`
268 UpdatedAt string `json:"updated_at"`
269 ClosedAt string `json:"closed_at"`
270 CloseReason string `json:"close_reason"`
271
272 Labels []string `json:"labels"`
273 }
274
275 // edgeJSON is one direct dependency edge, with the other end resolved to
276 // something readable.
277 type edgeJSON struct {
278 IssueID string `json:"issue_id"`
279 Title string `json:"title"`
280
281 // Type is the dependency type — "blocks", "parent-child", "related", … —
282 // and it matters: only an open "blocks" edge blocks, while parent-child is
283 // hierarchy.
284 Type string `json:"type"`
285 Status string `json:"status"`
286 Closed bool `json:"closed"`
287 }
288
289 // treeNodeJSON is one node of a flattened transitive dependency tree. Depth is
290 // the indentation level: 0 is a direct edge of the issue asked about.
291 type treeNodeJSON struct {
292 ID string `json:"id"`
293 Title string `json:"title"`
294 Type string `json:"type"`
295 Status string `json:"status"`
296 Closed bool `json:"closed"`
297 Depth int `json:"depth"`
298 }
299
300 // subtaskJSON is one child of an epic — the far end of a parent-child edge
301 // pointing at it.
302 type subtaskJSON struct {
303 ID string `json:"id"`
304 Title string `json:"title"`
305 Status string `json:"status"`
306 Category string `json:"category"`
307 Priority string `json:"priority"`
308 Assignee string `json:"assignee"`
309 Blocked bool `json:"blocked"`
310 }
311
312 type commentJSON struct {
313 Author string `json:"author"`
314 Text string `json:"text"`
315 CreatedAt string `json:"created_at"`
316 }
317
318 // activityJSON is one entry of the merged history: a comment, an audit event, or
319 // a dependency link (which beads records on the edge row rather than as an
320 // event). Summary is the humanised one-liner the projection builds
321 // ("changed status to in_progress"); Text carries a comment's body or an event's
322 // free-text note.
323 type activityJSON struct {
324 Kind string `json:"kind"`
325 Event string `json:"event"`
326 Actor string `json:"actor"`
327 Summary string `json:"summary"`
328 Text string `json:"text"`
329 CreatedAt string `json:"created_at"`
330 }
331
332 type getIssueOutput struct {
333 Ref string `json:"ref"`
334
335 // Issue is the issue asked for, and **null** when it was not among the rows
336 // read. Null occurs only together with table_truncated: a complete read that
337 // does not carry the id is the ordinary miss and has no payload at all. Read
338 // the two together — null here means "not in the first table_total rows this
339 // projection read", never "no such issue".
340 Issue *issueJSON `json:"issue"`
341
342 // IsEpic reports that this issue is a parent of subtasks, which is what makes
343 // the two rollup counts below meaningful.
344 IsEpic bool `json:"is_epic"`
345
346 // DependsOn is what this issue waits on; DependedOnBy is what waits on it.
347 // Both are the direct edges only.
348 DependsOn []edgeJSON `json:"depends_on"`
349 DependedOnBy []edgeJSON `json:"depended_on_by"`
350
351 // The transitive closures of those two directions, flattened pre-order with
352 // a depth. They are bounded by the projection (6 levels, 200 nodes) so a
353 // dense or cyclic graph cannot run away, and they are empty when they would
354 // only repeat the direct edges above.
355 DependsTree []treeNodeJSON `json:"depends_tree"`
356 DependentTree []treeNodeJSON `json:"dependent_tree"`
357
358 // The epic rollup: the children, how many are closed, and how many there
359 // are. Empty and zero for an issue that is not an epic.
360 Subtasks []subtaskJSON `json:"subtasks"`
361 SubtaskDone int `json:"subtask_done"`
362 SubtaskTotal int `json:"subtask_total"`
363
364 Comments []commentJSON `json:"comments"`
365
366 // History is the comments and the audit trail merged and sorted oldest
367 // first, which is the one place the *story* of an issue is readable.
368 History []activityJSON `json:"history"`
369
370 // TableTruncated is list_issues' flag under list_issues' name, and it is the
371 // same fact: some table this answer was assembled from exceeded the 2000-row
372 // cap and came back clipped. Here it covers more tables than a board does —
373 // the issue's labels, its comments and its events are read for this pane —
374 // so a true one says the edges, the thread or the history below may be short.
375 TableTruncated bool `json:"table_truncated"`
376
377 // TableTotal is the number of rows in the issues table at this ref: what
378 // exists, against the first 2000 that were read. It is what makes both the
379 // flag above and a null issue checkable rather than a bare warning.
380 TableTotal int `json:"table_total"`
381 }
382
383 type listMilestonesInput struct {
384 databaseRef
385 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"`
386 }
387
388 // milestoneMemberJSON is one issue under a milestone.
389 //
390 // It is a smaller shape than issueCardJSON on purpose rather than by omission:
391 // the milestone rollup does not compute the dependency counts or the ready flag,
392 // and reporting them as 0 and false here would be four lies an agent has no way
393 // to detect. Call list_issues with filter.label to get the full cards for a
394 // milestone's members.
395 type milestoneMemberJSON struct {
396 ID string `json:"id"`
397 Title string `json:"title"`
398 Type string `json:"type"`
399 Priority string `json:"priority"`
400 Assignee string `json:"assignee"`
401 Category string `json:"category"`
402 }
403
404 // milestoneEpicJSON is an epic inside a milestone with the members nested under
405 // it. A child nests only when it carries the same milestone label as its epic —
406 // membership is purely label-based.
407 type milestoneEpicJSON struct {
408 Issue milestoneMemberJSON `json:"issue"`
409 Done int `json:"done"`
410 Total int `json:"total"`
411 Children []milestoneMemberJSON `json:"children"`
412 }
413
414 // milestoneJSON is one "milestone:<name>" label's rollup and its members.
415 type milestoneJSON struct {
416 Name string `json:"name"`
417 Label string `json:"label"`
418
419 // The arithmetic of the rollup: Total is every issue carrying the label, and
420 // the three below partition it by status category.
421 Total int `json:"total"`
422 Done int `json:"done"`
423 InProgress int `json:"in_progress"`
424 Open int `json:"open"`
425
426 // The members, in the shallow hierarchy the projection arranges them in:
427 // the milestone's own issue(s) first, then its epics with their children
428 // nested, then everything else. Every member appears exactly once across the
429 // three, and the three together are Total issues.
430 Heads []milestoneMemberJSON `json:"heads"`
431 Epics []milestoneEpicJSON `json:"epics"`
432 Loose []milestoneMemberJSON `json:"loose"`
433 }
434
435 type listMilestonesOutput struct {
436 Ref string `json:"ref"`
437 Milestones []milestoneJSON `json:"milestones"`
438
439 // Unlabeled is how many issues carry no milestone label at all, and Total is
440 // every issue read. A tracker that uses no milestone labels answers an empty
441 // list with Unlabeled == Total, which is an answer and not an error.
442 Unlabeled int `json:"unlabeled"`
443 Total int `json:"total"`
444
445 // TableTruncated is list_issues' flag under list_issues' name: one of the
446 // tables this rollup is computed from — issues, labels, dependencies,
447 // custom_statuses — exceeded the 2000-row cap and came back clipped, so every
448 // count above is arithmetic over a prefix of the tracker rather than over it.
449 // A clipped labels table is the quietest of the four: membership itself goes
450 // missing, so a milestone can lose members rather than merely undercount them.
451 TableTruncated bool `json:"table_truncated"`
452
453 // TableTotal is the number of rows in the issues table at this ref — what
454 // exists, against Total, which is what was read. They differ exactly when the
455 // issues read was clipped, and that difference is the size of what this
456 // rollup did not see.
457 TableTotal int `json:"table_total"`
458 }
459
460 type listMemoriesInput struct {
461 databaseRef
462 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"`
463
464 // Query is the memory view's ?q= and is handed to the projection as such,
465 // rather than applied to the answer here: filtering afterwards would be a
466 // second reading of the same rule, and it would also make the tool pay for
467 // dating memories it is about to drop (the revision walk runs per key, after
468 // the narrowing).
469 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"`
470 }
471
472 // memoryRevisionJSON is when a memory's value last changed: the commit that
473 // wrote it, recovered from the history rather than read off the row — a config
474 // row is (key, value) and carries no timestamp at all
475 // (docs/DESIGN.views.md §2.1).
476 type memoryRevisionJSON struct {
477 Commit string `json:"commit"`
478 Date time.Time `json:"date"`
479 Author string `json:"author"`
480 }
481
482 // memoryJSON is one `bd remember` entry: the slug, the text, and what the walk
483 // could establish about when it was written.
484 //
485 // This is the one listing on this surface that carries prose, and it is not an
486 // exception to the list/detail split of §9.2 — it is the same rule applied. A
487 // memory *is* its text: there is no detail tool to send a caller to, and a
488 // listing of slugs alone would answer nothing. `q` is how a caller reads part of
489 // a large tracker's memories rather than all of them.
490 type memoryJSON struct {
491 // Slug is the config key with bd's "kv.memory." prefix stripped, and Text is
492 // the value with both newline spellings normalised — memories are typed into
493 // shell strings as often as into files, so the same tracker holds real
494 // newlines and literal "\n" escapes side by side.
495 Slug string `json:"slug"`
496 Text string `json:"text"`
497
498 // Revision is the commit that last wrote this value, or **null** when the
499 // revision walk could not reach it. Null is not "unknown for some reason": it
500 // occurs only when walk_truncated is true, and it means this memory was not
501 // written inside the last walk_max commits — i.e. it is older than that. (Not
502 // the converse: a truncated walk can still have dated every memory it was
503 // asked about.) Inventing a date the walk cannot support, or dropping the
504 // field, would both turn that fact into something a caller cannot see.
505 Revision *memoryRevisionJSON `json:"revision"`
506
507 // AgeDays is how long ago that revision was, in whole days, measured against
508 // the server's clock when the call was answered. It is null exactly when
509 // Revision is.
510 //
511 // It is carried beside the date rather than left to the caller because it is
512 // what Stale is computed from, and a flag whose input is invisible is a flag
513 // that has to be trusted.
514 AgeDays *int `json:"age_days"`
515
516 // Stale is the projection's question — not a verdict — about a memory older
517 // than stale_after_days: some memories are meant to be permanent, and only the
518 // reader knows which.
519 //
520 // It can be true while Revision is null, and that is not a contradiction: when
521 // the walk's own oldest commit is already past the threshold, the memory is at
522 // least that old, and that much is known without a date.
523 Stale bool `json:"stale"`
524 }
525
526 type listMemoriesOutput struct {
527 Ref string `json:"ref"`
528
529 // Memories are ordered by slug.
530 Memories []memoryJSON `json:"memories"`
531
532 // Total is how many memories the tracker holds before `q` narrowed them — the
533 // honest denominator of the list above, and the way a caller tells "this
534 // tracker has none" from "your search matched none".
535 Total int `json:"total"`
536
537 // WalkTruncated says the revision walk stopped at WalkMax commits with the
538 // history still going, which is the only way a memory here carries no
539 // revision. Read it with the null revisions above: it is the sentence "older
540 // than the last walk_max commits" that the page renders in place of a date.
541 WalkTruncated bool `json:"walk_truncated"`
542
543 // WalkMax is how many commits back the walk looks, so the sentence above can
544 // name its own number instead of asking a caller to trust a bound it cannot
545 // see.
546 WalkMax int `json:"walk_max"`
547
548 // StaleAfterDays is the threshold every Stale flag was computed against. It is
549 // one constant for the instance rather than a per-call knob, and it is
550 // published for the same reason age_days is: a caller that disagrees with the
551 // threshold can apply its own to the ages.
552 StaleAfterDays int `json:"stale_after_days"`
553 }
554
555 // --- registration -----------------------------------------------------------
556
557 // registerBeadsTools installs the tools of docs/DESIGN.mcp.md §9.2.
558 //
559 // The descriptions tell an agent choosing between them what each one costs: the
560 // listing is cheap and carries no bodies, the detail is one issue and carries
561 // all of them. An agent that reads that stops asking for a board it will not
562 // read.
563 126 func (s *Server) registerBeadsTools() {
564 126 mcp.AddTool(s.mcp, &mcp.Tool{
565 126 Name: "list_issues",
566 126 Annotations: readOnlyTool,
567 126 Description: "List the issues of a beads (bd) issue tracker hosted here, as cards: id, title, type, " +
568 126 "priority, assignee, labels, how many dependencies the issue has and how many point at it, " +
569 126 "whether it is ready to work, and which board lane it sits in.\n\n" +
570 126 "**No bodies.** Descriptions, design notes, acceptance criteria and comments are not in this " +
571 126 "answer at all — call get_issue for one issue when you need them. That split is what makes " +
572 126 "listing a whole tracker affordable.\n\n" +
573 126 "`filter` narrows the listing and every field is optional: `status` is the category " +
574 126 "(`open`, `in_progress`, `closed`), `type`, `priority`, `assignee` and `label` are exact " +
575 126 "matches, `q` is a case-insensitive substring of the id or title, and `ready: true` narrows " +
576 126 "to what can be worked now — open, unblocked, not a template. That is bd's own ready set, " +
577 126 "the answer to \"what should I pick up\".\n\n" +
578 126 "`limit` defaults to 200, is capped at 500 and is applied after filtering; `total` is how " +
579 126 "many issues matched before it, and `truncated` says matches were left behind. Separately, " +
580 126 "`table_truncated` says the tracker has more than 2000 issues and only the first 2000 were " +
581 126 "read, so the counts describe that prefix — `table_total` is the real number.\n\n" +
582 126 "`clipped` names every *other* table this listing drew from that also came back clipped — " +
583 126 "labels, custom_statuses — one entry per table with the rows read against the rows that exist " +
584 126 "and what that specific clip costs this listing: a clipped labels table takes the pills off " +
585 126 "every card and narrows what the `label` filter can match, and a clipped custom_statuses " +
586 126 "table can put a card in the wrong lane. `table_truncated`/`table_total` never move for " +
587 126 "either — they mean only the issues/dependencies read that decides `total` — so `clipped` is " +
588 126 "the only place those two clips are visible. It is an empty list on a complete read.\n\n" +
589 126 "A database that is not a beads tracker says so and points at the generic tools; a database " +
590 126 "you may not read is reported as not existing.",
591 126 }, func(ctx context.Context, _ *mcp.CallToolRequest, in listIssuesInput) (*mcp.CallToolResult, listIssuesOutput, error) {
592 51 out, err := s.listIssues(ctx, in)
593 51 return nil, out, err
594 51 })
595
596 126 mcp.AddTool(s.mcp, &mcp.Tool{
597 126 Name: "get_issue",
598 126 Annotations: readOnlyTool,
599 126 Description: "Read one issue of a hosted beads tracker whole: every modelled field including the four " +
600 126 "long texts (description, design, acceptance criteria, notes), both dependency directions " +
601 126 "with the titles and statuses of the other ends, the transitive dependency trees, the " +
602 126 "comments, and the merged history of comments and audit events oldest first.\n\n" +
603 126 "`id` is the issue id list_issues reports, e.g. \"bd-42\". An id the tracker does not carry " +
604 126 "is answered as such — about this database, which you are looking straight at. On a tracker " +
605 126 "of more than 2000 issues that answer changes, because it has to: only the first 2000 rows " +
606 126 "were read, so an id that is not among them is reported as *not read* rather than as absent, " +
607 126 "with `table_truncated: true` and the tracker's real `table_total` in the payload beside a " +
608 126 "null `issue`. read_rows pages past the cap when the tail is really wanted.\n\n" +
609 126 "When the issue is an epic, `is_epic` is true and `subtasks` carries its children with " +
610 126 "`subtask_done`/`subtask_total` as the rollup. `depends_on` is what this issue waits on and " +
611 126 "`depended_on_by` is what waits on it; only an open `blocks` edge actually blocks, while " +
612 126 "`parent-child` is hierarchy. The two trees flatten those directions transitively with a " +
613 126 "`depth`, and are empty when they would only repeat the direct edges.\n\n" +
614 126 "This is the only tool on this surface that carries issue bodies. Use list_issues to find " +
615 126 "the id, then this one for the issue you actually need.",
616 126 }, func(ctx context.Context, _ *mcp.CallToolRequest, in getIssueInput) (*mcp.CallToolResult, getIssueOutput, error) {
617 35 return s.getIssue(ctx, in)
618 35 })
619
620 126 mcp.AddTool(s.mcp, &mcp.Tool{
621 126 Name: "list_milestones",
622 126 Annotations: readOnlyTool,
623 126 Description: "Summarize a hosted beads tracker by milestone: for every \"milestone:<name>\" label, how " +
624 126 "many issues carry it and how many of those are done, in progress and open, plus the " +
625 126 "members themselves.\n\n" +
626 126 "Members are arranged the way the tracker means them: `heads` are the milestone's own " +
627 126 "issues (issue_type \"milestone\"), `epics` are its epics with their children nested and " +
628 126 "their own done/total, and `loose` is everything else. Each member appears exactly once " +
629 126 "across the three, and together they are `total`.\n\n" +
630 126 "Membership is purely label-based: an issue in several milestones counts under each, and a " +
631 126 "child nests under an epic only when it carries the same milestone label. `unlabeled` is " +
632 126 "how many issues carry no milestone label at all.\n\n" +
633 126 "A tracker that uses no milestone labels answers an empty list — that is an answer, not an " +
634 126 "error. For the full cards of one milestone's members, call list_issues with " +
635 126 "`filter.label` set to the label.\n\n" +
636 126 "`table_truncated` says the rollup was computed over a clipped read — the projection reads " +
637 126 "at most 2000 rows of a table in one pass — so every count above describes that prefix and " +
638 126 "not the whole tracker; `table_total` is how many issues really exist, against `total`, " +
639 126 "which is how many were read. A clipped `labels` table is the quiet one: membership itself " +
640 126 "goes missing, so a milestone can lose members rather than merely undercount them.",
641 126 }, func(ctx context.Context, _ *mcp.CallToolRequest, in listMilestonesInput) (*mcp.CallToolResult, listMilestonesOutput, error) {
642 26 out, err := s.listMilestones(ctx, in)
643 26 return nil, out, err
644 26 })
645
646 126 mcp.AddTool(s.mcp, &mcp.Tool{
647 126 Name: "list_memories",
648 126 Annotations: readOnlyTool,
649 126 Description: "List the memories a hosted beads tracker holds — what `bd remember` writes — each with its " +
650 126 "text and the revision its value was last written at.\n\n" +
651 126 "Memories are the other half of what a tracker knows: durable notes an agent left for the next " +
652 126 "one, stored as `kv.memory.<slug>` rows in the tracker's `config` table. Read them before " +
653 126 "planning work on a tracker; they are where its conventions, its gotchas and its handoffs live.\n\n" +
654 126 "A memory carries no timestamp — the row is (key, value) and nothing else — so `revision` is " +
655 126 "recovered from the history: the commit whose `config` table first differs is the one that " +
656 126 "wrote the value. That walk looks back at most `walk_max` commits. A memory not written inside " +
657 126 "it has `revision: null` and `age_days: null`, and `walk_truncated` is true: null means \"older " +
658 126 "than the last `walk_max` commits\", never \"date unavailable\".\n\n" +
659 126 "`stale` is a question, not a verdict — it marks a memory older than `stale_after_days`, and " +
660 126 "some memories are meant to be permanent. Both the age and the threshold are in the answer, so " +
661 126 "judge for yourself rather than trusting the flag. A memory can be `stale` with a null " +
662 126 "revision: the walk's oldest commit is already past the threshold.\n\n" +
663 126 "`q` is a case-insensitive substring of a slug or of a memory's text; `total` is how many " +
664 126 "memories the tracker holds before it narrowed them. A tracker whose `config` table holds no " +
665 126 "memory — or that has no `config` table at all — answers an empty list, which is an answer and " +
666 126 "not an error.\n\n" +
667 126 "A database that is not a beads tracker says so and points at the generic tools; a database " +
668 126 "you may not read is reported as not existing.",
669 126 }, func(ctx context.Context, _ *mcp.CallToolRequest, in listMemoriesInput) (*mcp.CallToolResult, listMemoriesOutput, error) {
670 32 out, err := s.listMemories(ctx, in)
671 32 return nil, out, err
672 32 })
673 }
674
675 // --- the handlers -----------------------------------------------------------
676
677 // listIssues answers list_issues: the board's cards, filtered, ordered and
678 // capped.
679 51 func (s *Server) listIssues(ctx context.Context, in listIssuesInput) (listIssuesOutput, error) {
680 51 const tool = "list_issues"
681 51 var out listIssuesOutput
682 51
683 51 limit, err := pageLimit(in.Limit, defaultIssueLimit, maxIssueLimit, "issues")
684 51 if err != nil {
685 2 return out, err
686 2 }
687 // An unrecognised category is refused rather than matched against nothing: a
688 // caller that typed "in-progress" would otherwise read an empty board as "no
689 // work is under way", which is a false statement about the tracker.
690 49 wantCategory, err := parseCategory(in.Filter.Status)
691 49 if err != nil {
692 1 return out, err
693 1 }
694
695 48 sess, ref, err := s.openTracker(ctx, tool, in.databaseRef, in.Ref)
696 48 if err != nil {
697 9 return out, err
698 9 }
699 39 defer sess.Close()
700 39
701 39 data, err := beads.Build(ctx, sess, ref, boardQuery(in.Filter))
702 39 if err != nil {
703 0 // The ref resolved and the fingerprint matched a moment ago (openTracker),
704 0 // so a failure here is a table this service could not read rather than a
705 0 // question about a ref or a database.
706 0 return out, internalError(err, tool)
707 0 }
708
709 39 out = listIssuesOutput{
710 39 Ref: ref,
711 39 Issues: []issueCardJSON{},
712 39 Limit: limit,
713 39 TableTruncated: data.Truncated,
714 39 TableTotal: data.ShownOf,
715 39 Clipped: clippedTablesJSON(data.Clipped),
716 39 }
717 156 for _, lane := range data.Lanes {
718 156 category, ok := laneCategory(lane.Slug)
719 156 if !ok {
720 0 // The slugs are the projection's own. An unknown one means beads changed
721 0 // its lanes and this mapping did not, and answering with a guessed
722 0 // category would be this surface quietly disagreeing with the board.
723 0 return listIssuesOutput{}, internalError(
724 0 fmt.Errorf("the beads projection reported an unknown lane %q", lane.Slug), tool)
725 0 }
726 156 if wantCategory != "" && category != wantCategory {
727 14 continue
728 }
729 2168 for _, c := range lane.Issues {
730 2168 out.Total++
731 2168 if len(out.Issues) >= limit {
732 1996 continue
733 }
734 172 labels := c.Labels
735 172 if labels == nil {
736 57 labels = []string{}
737 57 }
738 172 out.Issues = append(out.Issues, issueCardJSON{
739 172 ID: c.ID,
740 172 Title: c.Title,
741 172 Type: c.Type,
742 172 Priority: c.Priority,
743 172 Assignee: c.Assignee,
744 172 Labels: labels,
745 172 BlockedBy: c.BlockedBy,
746 172 Blocks: c.Blocks,
747 172 Ready: c.Ready,
748 172 Lane: lane.Name,
749 172 Category: category,
750 172 })
751 }
752 }
753 39 out.Truncated = out.Total > len(out.Issues)
754 39 return out, nil
755 }
756
757 // getIssue answers get_issue: one issue whole, bodies included.
758 //
759 // It is the one handler here that builds its own *mcp.CallToolResult, and only
760 // on one path: the miss over a clipped read, which is an error result that also
761 // carries the structured payload (a null issue beside table_truncated and
762 // table_total). An agent that reads only the sentence learns the same thing, and
763 // one that decodes the payload can tell that miss from the ordinary one without
764 // parsing prose. Every other path returns a nil result and lets the SDK build it.
765 35 func (s *Server) getIssue(ctx context.Context, in getIssueInput) (*mcp.CallToolResult, getIssueOutput, error) {
766 35 const tool = "get_issue"
767 35 var out getIssueOutput
768 35
769 35 id := strings.TrimSpace(in.ID)
770 35 if id == "" {
771 1 return nil, out, errors.New("name the issue to read; list_issues reports the ids of a tracker")
772 1 }
773
774 34 sess, ref, err := s.openTracker(ctx, tool, in.databaseRef, in.Ref)
775 34 if err != nil {
776 9 return nil, out, err
777 9 }
778 25 defer sess.Close()
779 25
780 25 data, err := beads.Build(ctx, sess, ref, url.Values{"issue": {id}})
781 25 if err != nil {
782 0 return nil, out, internalError(err, tool)
783 0 }
784
785 25 out = getIssueOutput{
786 25 Ref: ref,
787 25 DependsOn: edgesOf(data.DependsOn),
788 25 DependedOnBy: edgesOf(data.DependedOnBy),
789 25 DependsTree: treeOf(data.DependsTree),
790 25 DependentTree: treeOf(data.DependentTree),
791 25 Subtasks: make([]subtaskJSON, 0, len(data.Subtasks)),
792 25 SubtaskDone: data.SubtaskDone,
793 25 SubtaskTotal: data.SubtaskTotal,
794 25 Comments: make([]commentJSON, 0, len(data.Comments)),
795 25 History: make([]activityJSON, 0, len(data.History)),
796 25 TableTruncated: data.Truncated,
797 25 TableTotal: data.ShownOf,
798 25 }
799 25 if data.Issue == nil {
800 4 // Two misses, and which one this is belongs to the projection: an issues
801 4 // table clipped at beads.Max means the id may sit in the tail nobody read,
802 4 // and answering "no such issue" there would state something this service
803 4 // does not know (beads.Data.MissingBeyondCap).
804 4 if data.MissingBeyondCap() {
805 2 return toolMiss(notAmongTheIssuesRead(in.databaseRef, ref, id, data.ShownOf)), out, nil
806 2 }
807 // The read was complete, so the id is genuinely not there. An ordinary
808 // answer about a database the caller can see, in refMiss's sense: naming
809 // the id back is not a leak, it is what the caller asked with.
810 2 return nil, getIssueOutput{}, errors.New(noSuchIssue(in.databaseRef, ref, id))
811 }
812 21 out.Issue = issueOf(data.Issue)
813 21 out.IsEpic = data.Mode == "epic"
814 21 for _, st := range data.Subtasks {
815 14 out.Subtasks = append(out.Subtasks, subtaskJSON{
816 14 ID: st.ID,
817 14 Title: st.Title,
818 14 Status: st.Status,
819 14 Category: st.Category,
820 14 Priority: st.Priority,
821 14 Assignee: st.Assignee,
822 14 Blocked: st.Blocked,
823 14 })
824 14 }
825 21 for _, c := range data.Comments {
826 5 out.Comments = append(out.Comments, commentJSON{Author: c.Author, Text: c.Text, CreatedAt: c.CreatedAt})
827 5 }
828 36 for _, a := range data.History {
829 36 out.History = append(out.History, activityJSON{
830 36 Kind: a.Kind,
831 36 Event: a.Event,
832 36 Actor: a.Actor,
833 36 Summary: a.Summary,
834 36 Text: a.Text,
835 36 CreatedAt: a.CreatedAt,
836 36 })
837 36 }
838 21 return nil, out, nil
839 }
840
841 // listMilestones answers list_milestones: the milestone: labels rolled up.
842 26 func (s *Server) listMilestones(ctx context.Context, in listMilestonesInput) (listMilestonesOutput, error) {
843 26 const tool = "list_milestones"
844 26 var out listMilestonesOutput
845 26
846 26 sess, ref, err := s.openTracker(ctx, tool, in.databaseRef, in.Ref)
847 26 if err != nil {
848 9 return out, err
849 9 }
850 17 defer sess.Close()
851 17
852 17 view, err := beads.BuildMilestones(ctx, sess, ref)
853 17 if err != nil {
854 0 return out, internalError(err, tool)
855 0 }
856
857 17 out = listMilestonesOutput{
858 17 Ref: ref,
859 17 Milestones: make([]milestoneJSON, 0, len(view.Milestones)),
860 17 Unlabeled: view.Unlabeled,
861 17 Total: view.Total,
862 17 TableTruncated: view.Truncated,
863 17 TableTotal: view.ShownOf,
864 17 }
865 19 for _, m := range view.Milestones {
866 19 entry := milestoneJSON{
867 19 Name: m.Name,
868 19 Label: m.Label,
869 19 Total: m.Total,
870 19 Done: m.Done,
871 19 InProgress: m.InProgress,
872 19 Open: m.Open,
873 19 Heads: membersOf(m.Heads),
874 19 Epics: make([]milestoneEpicJSON, 0, len(m.Epics)),
875 19 Loose: membersOf(m.Loose),
876 19 }
877 19 for _, e := range m.Epics {
878 9 entry.Epics = append(entry.Epics, milestoneEpicJSON{
879 9 Issue: memberOf(e.Card),
880 9 Done: e.Done,
881 9 Total: e.Total,
882 9 Children: membersOf(e.Children),
883 9 })
884 9 }
885 19 out.Milestones = append(out.Milestones, entry)
886 }
887 17 return out, nil
888 }
889
890 // listMemories answers list_memories: the memories a tracker holds, each dated
891 // from the history by beads.BuildMemories.
892 //
893 // The clock is real (time.Now) and is passed into the projection rather than
894 // read inside it, exactly as the web view passes its own: staleness is the one
895 // thing about this answer that depends on when it was computed, and beads/
896 // reads no hidden clock.
897 //
898 // # Why the fingerprint here is still beads.Applies
899 //
900 // beads.AppliesMemories exists — the beads fingerprint plus a config table with
901 // key and value — and it is what decides whether the web board grows a Memory
902 // tab. It is *not* what this tool refuses on, and the difference matters.
903 // openTracker's refusal says "this database is not a beads issue tracker", and
904 // for a tracker whose config table simply is not there that sentence would be
905 // false: it is a tracker, it has no memories, and "no memories" is an answer the
906 // projection already gives (a missing config table degrades to an empty view,
907 // like every other optional table). A tab is a question about a page's layout; a
908 // tool call is a question about the data, and the data here is "none".
909 32 func (s *Server) listMemories(ctx context.Context, in listMemoriesInput) (listMemoriesOutput, error) {
910 32 const tool = "list_memories"
911 32 var out listMemoriesOutput
912 32
913 32 sess, ref, err := s.openTracker(ctx, tool, in.databaseRef, in.Ref)
914 32 if err != nil {
915 9 return out, err
916 9 }
917 23 defer sess.Close()
918 23
919 23 // BrowseSession's method set covers beads.MemorySession (Rows, plus Log and
920 23 // TableHash, which the walk needs and only it needs), so the seam is handed
921 23 // over as it is rather than adapted.
922 23 now := time.Now()
923 23 view, err := beads.BuildMemories(ctx, sess, ref, memoryQuery(in.Query), now)
924 23 if err != nil {
925 0 // The ref resolved and the fingerprint matched a moment ago, so what failed
926 0 // here is a table or a history this service could not read. In particular a
927 0 // history that cannot be walked is an error and not a page of memories with
928 0 // every date quietly missing — that page is indistinguishable from a tracker
929 0 // whose memories are all older than the walk.
930 0 return out, internalError(err, tool)
931 0 }
932
933 23 out = listMemoriesOutput{
934 23 Ref: ref,
935 23 Memories: make([]memoryJSON, 0, len(view.Memories)),
936 23 Total: view.Total,
937 23 WalkTruncated: view.WalkTruncated,
938 23 WalkMax: view.WalkMax,
939 23 StaleAfterDays: int(beads.MemoryStaleAfter / (24 * time.Hour)),
940 23 }
941 23 for _, m := range view.Memories {
942 17 entry := memoryJSON{Slug: m.Slug, Text: m.Text, Stale: m.Stale}
943 17 if m.Revision != nil {
944 13 entry.Revision = &memoryRevisionJSON{
945 13 Commit: m.Revision.Commit,
946 13 Date: m.Revision.Date,
947 13 Author: m.Revision.Author,
948 13 }
949 13 // Whole days, measured against the same clock the projection judged
950 13 // Stale with — two answers from one reading rather than two.
951 13 days := int(now.Sub(m.Revision.Date) / (24 * time.Hour))
952 13 entry.AgeDays = &days
953 13 }
954 17 out.Memories = append(out.Memories, entry)
955 }
956 23 return out, nil
957 }
958
959 // --- resolving a tracker ----------------------------------------------------
960
961 // openTracker is the whole preamble of a beads tool: resolve the database as the
962 // browse handlers do, open its store, settle the ref, and refuse a database that
963 // is not a tracker.
964 //
965 // The session is the caller's to close (defer sess.Close()), which is the browse
966 // discipline; returning it rather than a closure keeps that visible at the call
967 // site instead of hidden in a helper.
968 140 func (s *Server) openTracker(ctx context.Context, tool string, ref databaseRef, named string) (BrowseSession, string, error) {
969 140 _, sess, at, err := s.openTrackerFor(ctx, tool, ref, named)
970 140 return sess, at, err
971 140 }
972
973 // openTrackerFor is openTracker plus the repository row it resolved.
974 //
975 // The row is what a tool needs when it addresses a database by something other
976 // than its {owner, name} — ready_work keys the shared projection cache on the
977 // repository id, which is the identity no two databases share and which survives
978 // a rename (beads.ReadyDatabase). Every other tool here wants only the session
979 // and the ref, and openTracker above is that same call with the row dropped:
980 // one preamble, so that the refusals cannot drift apart between tools.
981 func (s *Server) openTrackerFor(ctx context.Context, tool string, ref databaseRef, named string) (
982 *core.Repo, BrowseSession, string, error,
983 148 ) {
984 148 repo, err := s.resolveDatabase(ctx, tool, ref)
985 148 if err != nil {
986 26 return nil, nil, "", err
987 26 }
988 122 sess, err := s.openStore(ctx, tool, repo)
989 122 if err != nil {
990 0 return nil, nil, "", err
991 0 }
992
993 122 at, err := refFor(ctx, sess, tool, ref, named)
994 122 if err != nil {
995 0 sess.Close()
996 0 return nil, nil, "", err
997 0 }
998
999 122 tables, err := sess.Tables(ctx, at)
1000 122 if err != nil {
1001 4 sess.Close()
1002 4 return nil, nil, "", refMiss(err, tool, noSuchRef(ref, at))
1003 4 }
1004 // The fingerprint is beads.Applies and is asked here, per call, because MCP's
1005 // tool list is static per server: these tools are advertised for every
1006 // database on the instance, so "is this one a tracker" is a question about the
1007 // argument rather than about the surface (docs/DESIGN.mcp.md §9).
1008 118 if !beads.Applies(tables) {
1009 9 sess.Close()
1010 9 return nil, nil, "", errors.New(notATracker(ref, at))
1011 9 }
1012 109 return repo, sess, at, nil
1013 }
1014
1015 // boardQuery renders the tool's filter as the query the board projection parses,
1016 // so that the filtering is beads.Filter's and not a second implementation of it
1017 // reading the same rows.
1018 //
1019 // Status is absent from it deliberately: the projection has no status filter (a
1020 // board shows every lane at once), so the category narrowing is applied to the
1021 // lanes it answers with, in listIssues.
1022 39 func boardQuery(f issueFilter) url.Values {
1023 39 q := url.Values{}
1024 195 set := func(key, value string) {
1025 195 if value = strings.TrimSpace(value); value != "" {
1026 9 q.Set(key, value)
1027 9 }
1028 }
1029 39 set("q", f.Query)
1030 39 set("type", f.Type)
1031 39 set("priority", f.Priority)
1032 39 set("assignee", f.Assignee)
1033 39 set("label", f.Label)
1034 39 if f.Ready {
1035 1 q.Set("ready", "1")
1036 1 }
1037 39 return q
1038 }
1039
1040 // memoryQuery renders the tool's one filter as the query the memory projection
1041 // parses, for boardQuery's reason: the substring rule is beads' and not a second
1042 // implementation of it reading the same rows.
1043 //
1044 // The projection's other two parameters are deliberately not offered. ?key= is
1045 // ?q= with an exactness this tool has no use for — a slug is a substring of
1046 // itself — and ?sort= is a page's toggle: an answer with a stated order (slug)
1047 // is one an agent can sort itself, and every ordering the projection can produce
1048 // is derivable from the fields carried here.
1049 23 func memoryQuery(q string) url.Values {
1050 23 values := url.Values{}
1051 23 if q = strings.TrimSpace(q); q != "" {
1052 6 values.Set("q", q)
1053 6 }
1054 23 return values
1055 }
1056
1057 // parseCategory validates the status filter: empty is no constraint, one of the
1058 // three categories is itself, and anything else is refused with the three named.
1059 49 func parseCategory(want string) (string, error) {
1060 49 switch strings.ToLower(strings.TrimSpace(want)) {
1061 43 case "":
1062 43 return "", nil
1063 1 case categoryOpen:
1064 1 return categoryOpen, nil
1065 1 case categoryInProgress:
1066 1 return categoryInProgress, nil
1067 3 case categoryClosed:
1068 3 return categoryClosed, nil
1069 1 default:
1070 1 return "", fmt.Errorf("filter.status must be %q, %q or %q, not %q; "+
1071 1 "individual status names are per-tracker and are not filterable",
1072 1 categoryOpen, categoryInProgress, categoryClosed, want)
1073 }
1074 }
1075
1076 // laneCategory maps a board lane to the status category it was bucketed from.
1077 // Two lanes share "open": an open issue is Stalled when something open blocks it
1078 // and Lined Up otherwise, which is a distinction about blockers rather than
1079 // about status.
1080 //
1081 // An unknown slug is not defaulted — see listIssues, which turns it into a
1082 // failure rather than a guess.
1083 156 func laneCategory(slug string) (string, bool) {
1084 156 switch slug {
1085 39 case "rolling":
1086 39 return categoryInProgress, true
1087 39 case "past-stand":
1088 39 return categoryClosed, true
1089 78 case "lined-up", "stalled":
1090 78 return categoryOpen, true
1091 0 default:
1092 0 return "", false
1093 }
1094 }
1095
1096 // --- the projections --------------------------------------------------------
1097
1098 // issueOf renders the issue the projection found. It answers a pointer because
1099 // get_issue's field is one: the shape has to be able to say "not read", and only
1100 // a projection that found an issue ever reaches here.
1101 21 func issueOf(i *beads.Issue) *issueJSON {
1102 21 labels := i.Labels
1103 21 if labels == nil {
1104 9 labels = []string{}
1105 9 }
1106 21 return &issueJSON{
1107 21 ID: i.ID,
1108 21 Title: i.Title,
1109 21 Status: i.Status,
1110 21 IssueType: i.IssueType,
1111 21 Priority: i.Priority,
1112 21 Lane: i.Lane,
1113 21 Assignee: i.Assignee,
1114 21 CreatedBy: i.CreatedBy,
1115 21 Owner: i.Owner,
1116 21 EstimatedMinutes: i.EstimatedMinutes,
1117 21 ExternalRef: i.ExternalRef,
1118 21 SpecID: i.SpecID,
1119 21 Description: i.Description,
1120 21 Design: i.Design,
1121 21 AcceptanceCriteria: i.AcceptanceCriteria,
1122 21 Notes: i.Notes,
1123 21 CreatedAt: i.CreatedAt,
1124 21 StartedAt: i.StartedAt,
1125 21 UpdatedAt: i.UpdatedAt,
1126 21 ClosedAt: i.ClosedAt,
1127 21 CloseReason: i.CloseReason,
1128 21 Labels: labels,
1129 21 }
1130 }
1131
1132 50 func edgesOf(edges []beads.Edge) []edgeJSON {
1133 50 out := make([]edgeJSON, 0, len(edges))
1134 50 for _, e := range edges {
1135 21 out = append(out, edgeJSON{
1136 21 IssueID: e.IssueID,
1137 21 Title: e.Title,
1138 21 Type: e.Type,
1139 21 Status: e.Status,
1140 21 Closed: e.Closed,
1141 21 })
1142 21 }
1143 50 return out
1144 }
1145
1146 50 func treeOf(nodes []beads.TreeNode) []treeNodeJSON {
1147 50 out := make([]treeNodeJSON, 0, len(nodes))
1148 50 for _, n := range nodes {
1149 10 out = append(out, treeNodeJSON{
1150 10 ID: n.ID,
1151 10 Title: n.Title,
1152 10 Type: n.Type,
1153 10 Status: n.Status,
1154 10 Closed: n.Closed,
1155 10 Depth: n.Depth,
1156 10 })
1157 10 }
1158 50 return out
1159 }
1160
1161 55 func memberOf(c beads.Card) milestoneMemberJSON {
1162 55 return milestoneMemberJSON{
1163 55 ID: c.ID,
1164 55 Title: c.Title,
1165 55 Type: c.Type,
1166 55 Priority: c.Priority,
1167 55 Assignee: c.Assignee,
1168 55 Category: c.Category,
1169 55 }
1170 55 }
1171
1172 47 func membersOf(cards []beads.Card) []milestoneMemberJSON {
1173 47 out := make([]milestoneMemberJSON, 0, len(cards))
1174 47 for _, c := range cards {
1175 46 out = append(out, memberOf(c))
1176 46 }
1177 47 return out
1178 }
1179
1180 // --- the sentences a caller reads -------------------------------------------
1181
1182 // notATracker is the refusal of docs/DESIGN.mcp.md §9: a database the caller may
1183 // read, whose tables are not a beads tracker. It names the generic tools,
1184 // because the database is perfectly readable — just not as issues — and it is
1185 // pointedly not the masked not-found: the caller is looking straight at this
1186 // database.
1187 9 func notATracker(ref databaseRef, at string) string {
1188 9 return fmt.Sprintf("%s is not a beads issue tracker at %q: its tables carry no beads schema "+
1189 9 "(an \"issues\" table with id and status columns, and a \"dependencies\" table). "+
1190 9 "It is still a database you can read — list_tables names its tables and read_rows reads them.",
1191 9 ref, at)
1192 9 }
1193
1194 // noSuchIssue is an ordinary answer about a tracker the caller can see, and it
1195 // is only ever said about a *complete* read: the projection saw every issue
1196 // there is, and this id is not one of them.
1197 2 func noSuchIssue(ref databaseRef, at, id string) string {
1198 2 return fmt.Sprintf("%s has no issue %q at %q; list_issues names the issues there", ref, id, at)
1199 2 }
1200
1201 // notAmongTheIssuesRead is the other miss: the issues table exceeded the cap, so
1202 // what this service knows is that the id is not in the rows it read — not that
1203 // it does not exist. The sentence carries both numbers the claim rests on (the
1204 // cap and the tracker's true total) so that a caller can check it, and it names
1205 // the way past the cap rather than leaving an agent with a dead end.
1206 2 func notAmongTheIssuesRead(ref databaseRef, at, id string, total int) string {
1207 2 return fmt.Sprintf("%s carries %d issues at %q and this projection reads the first %d of them "+
1208 2 "in one pass: %q is not among the rows read, which is not the same as saying it does not "+
1209 2 "exist (table_truncated: true, table_total: %d). read_rows pages through the whole issues "+
1210 2 "table; list_issues with a filter narrows the tracker to a board that fits under the cap.",
1211 2 ref, total, at, beads.Max, id, total)
1212 2 }
1213
1214 // toolMiss is an error result whose text this package wrote — the shape the SDK
1215 // builds for a returned error, built here instead so that the structured payload
1216 // can travel with it. It is used by the one answer that is both a refusal and a
1217 // fact worth decoding (get_issue past the cap); everything else returns an error
1218 // and lets the SDK pack it.
1219 2 func toolMiss(text string) *mcp.CallToolResult {
1220 2 return &mcp.CallToolResult{
1221 2 IsError: true,
1222 2 Content: []mcp.Content{&mcp.TextContent{Text: text}},
1223 2 }
1224 2 }