| 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 |
} |