✅ fresh
last synced 2026-09-29T21:12:19.681221+00:00 · coverage 100% (
mcp-server)Validation by Beadloom
doc_sync— same source assync-check.
MCP Server
Beadloom provides an MCP (Model Context Protocol) server with 18 tools for integration with AI agents: 14 read/write tools over the architecture graph, plus four process-tools (task_init / bead_context / complete_bead / checkpoint, added in BDL-048) that make the deterministic steps of Beadloom's multi-agent dev flow callable from any MCP client. See the Agentic Dev Flow guide.
Specification
Transport
The server operates via stdio transport. Launch:
beadloom mcp-serve [--project DIR]Configuration for supported editors/tools:
# Claude Code (default) — writes .mcp.json
beadloom setup-mcp
# Cursor — writes .cursor/mcp.json
beadloom setup-mcp --tool cursor
# Windsurf — writes ~/.codeium/windsurf/mcp_config.json (global)
beadloom setup-mcp --tool windsurf
# Remove configuration
beadloom setup-mcp --removeClaude Code (.mcp.json):
{
"mcpServers": {
"beadloom": {
"command": "beadloom",
"args": ["mcp-serve"]
}
}
}Cursor (.cursor/mcp.json):
{
"mcpServers": {
"beadloom": {
"command": "beadloom",
"args": ["mcp-serve"]
}
}
}Windsurf (~/.codeium/windsurf/mcp_config.json):
{
"mcpServers": {
"beadloom": {
"command": "beadloom",
"args": ["mcp-serve", "--project", "/path/to/project"]
}
}
}Note: Windsurf uses a global config, so the --project path is automatically included.
Features
- Auto-reindex: before each tool call, checks if the index is stale by comparing file mtimes with
last_reindex_at. If stale, runsincremental_reindex()transparently. - Two-level caching: L1 in-memory
ContextCacheforget_contextandget_graph(keyed by ref_id + params + file mtimes), L2SqliteCachefor persistence across calls. Cache is invalidated onupdate_nodecalls and after auto-reindex.
Available Tools
get_context
Get a context bundle for a set of ref_id(s).
{
"name": "get_context",
"arguments": {
"ref_id": "context-oracle",
"depth": 2,
"max_nodes": 20,
"max_chunks": 10
}
}Returns JSON with fields: version, focus, graph (nodes + edges), text_chunks, code_symbols, sync_status, constraints, intent, routes, tests, test_placements, test_unplaced, test_recognition. test_placements (BDL-074 C2, additive) counts the project's indexed test files by placement; a non-zero unplaced means the counts under tests can be short. test_unplaced (BDL-074 G2) states that share in one sentence, null when no file is unplaced. test_recognition (beadloom-2mj3.15, additive) states which paths a test file is read under, null for an index with no recorded test layout. Supports L1/L2 caching -- returns {"cached": true, "etag": ..., "hint": ...} when unchanged.
The intent field carries the epics whose planning documents declared the focus node, so an agent asking what a node IS also learns what it is FOR. It is filled when the server knows the project root, and reports {"status": "not_checked", "reason": "intent_space_not_read"} when it does not -- a different statement from "no epic declares this node", which is {"status": "none_declared"} and carries the number of epics that were read. See docs/domains/context-oracle/components/node-intent/DOC.md.
get_graph
Get a subgraph from specified nodes or the entire graph.
{
"name": "get_graph",
"arguments": {
"ref_id": "beadloom",
"depth": 2
}
}Supports L1/L2 caching with mtime-based invalidation.
list_nodes
List all nodes in the architecture graph.
{
"name": "list_nodes",
"arguments": {
"kind": "domain"
}
}kind is optional. When provided, filters by node type: domain, feature, service, entity, adr.
sync_check
Check doc-code synchronization.
{
"name": "sync_check",
"arguments": {
"ref_id": "context-oracle"
}
}Returns list of sync pairs with status, ref_id, doc_path, code_path, reason, and optional details.
Since BDL-061 S4b the result also carries incomplete rows — a document that is current and does not carry the shape its kind requires (missing_sections, section_not_in_use). They are warn: reported, never blocking. The tool resolves the required sections from the project's composed doc templates, so an ref_id-less call is the honest whole-project view.
get_status
Get index statistics.
{
"name": "get_status",
"arguments": {}
}Returns: nodes_count, edges_count, docs_count, chunks_count, symbols_count, stale_count, doc_coverage, last_reindex, beadloom_version.
stale_count counts doc-code PAIRS — one sync_state row per document AND code file, so one README over three code files contributes three. The key is unchanged; the tool's own description said "stale doc count" over that number until BDL-069 beadloom-rqma.5 and now says pair.
update_node
Update a graph node's summary or source path in YAML and SQLite.
{
"name": "update_node",
"arguments": {
"ref_id": "context-oracle",
"summary": "Updated description",
"source": "src/beadloom/context_oracle/"
}
}Invalidates L1 and L2 cache for the affected ref_id.
mark_synced
Mark all doc-code pairs for a ref_id as synced (after updating docs).
{
"name": "mark_synced",
"arguments": {
"ref_id": "context-oracle"
}
}Returns: { "ref_id": "...", "pairs_synced": N }.
search
Search nodes and documentation by keyword (FTS5 with LIKE fallback).
{
"name": "search",
"arguments": {
"query": "context",
"kind": "domain",
"limit": 10
}
}generate_docs
Generate structured documentation data from the architecture graph for AI-driven enrichment.
{
"name": "generate_docs",
"arguments": {
"ref_id": "context-oracle"
}
}Returns JSON with: nodes (ref_id, kind, summary, source, symbols, dependencies, existing_doc, symbol_changes), architecture (mermaid diagram), and instructions (AI enrichment prompt). Omit ref_id for all nodes.
prime
Get compact project context for session start. Call this at the beginning of every session.
{
"name": "prime",
"arguments": {}
}Returns JSON with: project name, version, architecture summary (domain/service/feature counts, symbols), health (lint violations, last reindex, and stale_docs, one entry per stale doc-code pair carrying doc_path, code_path and ref_id), architecture rules, domain list, and agent instructions.
why
Impact analysis: show upstream dependencies and downstream dependents for a node.
{
"name": "why",
"arguments": {
"ref_id": "context-oracle"
}
}Returns: ref_id, flattened upstream list, flattened downstream list, and impact_summary.
diff
Show graph changes since a git ref (commit, branch, tag).
{
"name": "diff",
"arguments": {
"since": "HEAD~1"
}
}Returns: since, added_nodes, removed_nodes, changed_nodes (with old/new summaries), added_edges, removed_edges.
lint
Run architecture lint rules. Returns violations as JSON.
{
"name": "lint",
"arguments": {
"severity": "all"
}
}severity filter: all (default), error, warn. Returns: violations list (each with rule, severity, rule_type, file_path, line_number, from_ref_id, to_ref_id, message) and summary (errors, warnings, rules_evaluated).
get_debt_report
Get architecture debt report with score, categories, and top offenders.
{
"name": "get_debt_report",
"arguments": {
"trend": true,
"category": "rule_violations"
}
}trend (boolean, default false): include trend vs last snapshot. category (string, optional): filter to specific category -- accepts rule_violations, doc_gaps, complexity, test_gaps (or short names: rules, docs, tests). Returns: debt_score (0-100), severity (clean/low/medium/high/critical), categories list (each with name, score, details), top_offenders list (each with ref_id, score, reasons), trend (null or object with previous_snapshot, previous_score, delta, category_deltas), layer_populations, and test_population (BDL-074 C2): what the untested count was taken over, or not counted: ... while test files are unplaced. With trend the handler attaches the trend by dataclasses.replace, so both population keys survive; the hand-built copy it replaced dropped them.
Process-tools (BDL-048)
Four tools that expose the deterministic steps of Beadloom's multi-agent dev flow to any MCP client. They are single deterministic operations — they do NOT orchestrate or spawn sub-agents (orchestration stays in the harness; see the honest boundary in the Agentic Dev Flow guide). The three bead-touching tools drive the bd (beads) CLI through a thin, mockable seam (services/bd_seam/client.py:run_bd); if bd is absent they return a structured {"status": "ERROR", ...}.
task_init
Scaffold a work item: create its docs folder + per-type skeletons and a valid 4-role bead DAG.
{
"name": "task_init",
"arguments": {
"type": "feature",
"key": "ABC-123"
}
}type (one of epic, feature, bug, task, chore) selects the doc set (PRD/RFC/CONTEXT/PLAN/ACTIVE for epic/feature; BRIEF/ACTIVE otherwise) and the bead type. key names the .claude/development/docs/features/<key>/ folder. The dev → test → review → tech-writer DAG is created as ONE plan in ONE bd create --graph … --json process, and every edge in that plan names a ROLE KEY rather than a bead id — so the tool authors no id and there is none to get wrong (BDL-UX #171), and seven bd processes became one (BDL-UX #165). The ids come back in bd's own answer. Returns { "status": "OK", "bead_ids": [...], "doc_paths": [...] } (or {"status": "ERROR", ...} with the partial doc_paths; an answer that cannot be read is an ERROR rather than a scaffold reporting no beads).
bead_context
Return ONE structured payload for a bead: graph context + impact + doc excerpt + active rules.
{
"name": "bead_context",
"arguments": {
"bead": "bd-42"
}
}Resolves the bead's graph ref from a ref:, refs: or area: token in the bead's design/description via bd show, then reuses context_oracle (ctx + why) and graph/rule_engine (active rules). Read-only and deterministic. Returns { "status": "OK", "bead", "ref_id", "context", "impact", "active_rules", "doc_excerpt" } (a CONTEXT.md/ACTIVE.md excerpt when locatable, else null). Returns {"status": "ERROR", ...} when the ref cannot be resolved or is not in the graph.
complete_bead
The refusing completion gate: run beadloom ci (+ tests) before closing a bead.
{
"name": "complete_bead",
"arguments": {
"bead": "bd-42",
"run_tests": true
}
}Runs the beadloom ci gate (reindex → lint → sync-check → docs audit → docs-quality → doc-spaces → config-check → doctor, via application/gate.run_ci_gate) and, when run_tests is true (the default), the test suite. On PASS it closes the bead (bd close --suggest-next), confirms what that suggestion named against bd ready --limit 0, best-effort flips the bead's row in the epic's ACTIVE.md bead-status table to ✓ done, and returns { "status": "PASS", "bead", "findings": [], "room": {...}, "next": [...], "next_candidates": [...], "next_still_blocked": [...], "next_stated": "...", "active_updated": <bool> }.
The confirmation is BDL-UX #97 answered where it reaches an agent (BDL-068 S5, beadloom-0mdo.52). bd close --suggest-next names beads for which the closed issue was a blocker without checking whether others remain: measured over twenty-three dependency shapes in twenty-three separate rigs on bd 1.0.4 it named a still-blocked bead in sixteen of them, and bd ready was correct in all twenty-three. This handler previously returned close.stdout verbatim under next, so an agent finishing a bead through Beadloom's own tool was handed that list unqualified. Now next holds only the candidates bd ready confirms, next_candidates holds what bd suggested, next_still_blocked holds the difference, and next_stated is the sentence a client shows instead of a bare list. When bd ready cannot be read — a non-zero exit, an unparseable answer, or bd gone between the two calls — next is empty and next_stated says not compared: the bead is already closed by then, so a failed confirmation is not an error, it is a confirmation nobody made, and reporting it as an empty ready queue would be a measurement nobody took. --limit 0 because bd ready caps at 100 rows and announces that on stderr only. On FAIL it does NOT close the bead and leaves the table untouched — it returns { "status": "FAIL", "bead", "findings": [...], "room": {...} } so the agent must fix the findings first. Both verdicts carry room (BDL-068 S3.2): { "current", "declared", "entered", "not_entered" } — the room the verdict was taken in and how much of the project's declared set it covers. It changes no status and adds no finding; it makes the verdict answerable rather than stronger. Both also carry not_run (BDL-068 S6, BDL-UX #247): the verifications this project's pipeline declares that no step of the run performed, by name. When run_tests is true the handler tells the gate so (performed_elsewhere=("tests",)), so one run cannot report the suite as not run while that run ran it; with run_tests=false the suite is named there, which is the whole point of the field. On FAIL it also carries owners (BDL-068 S6): one verdict per finding, held against the beads the tracker reports claimed while the run happened — owned with the bead ids, unowned for a node no claim covers, unattributed for a finding no node could be derived from, and a reason when the tracker could not answer at all. The agent reading a FAIL holds one of those beads, so the distinction it needs is between a finding its own claim covers and one nobody's does; before this, that sentence had to be written by a coordinator for every gate owner of two waves. It changes neither the verdict nor which findings are returned. Set run_tests=false for a fast gate-only check (skips the suite). This gate is advisory-strong, not the true enforcement point — beadloom ci in CI remains the single source of true enforcement.
checkpoint
Record a checkpoint: a bd comments add plus ACTIVE.md note + bead-status table row update.
{
"name": "checkpoint",
"arguments": {
"bead": "bd-42",
"text": "CHECKPOINT: wired the parser",
"status": "in progress"
}
}Adds text as a bead comment (preserves history) and, best-effort: appends a timestamped progress line to the bead's ACTIVE.md AND flips the bead's row in the ACTIVE.md bead-status table to status (default "in progress"). Both ACTIVE.md updates are skipped cleanly when the file/table/row cannot be located. Returns { "status": "OK", "bead", "comment_added": true, "active_updated": <bool>, "table_updated": <bool> }.
The bead-status table updater (_set_active_table_status) is deterministic and tolerant: it matches the bead-id as a whole token in the row's first cell (so …mukc.1 never collaterally matches …mukc.10), replaces the row's last (status) cell, and preserves every other row and the file's formatting; a missing file, no table, or no matching row is a no-op (returns False, file unchanged). It never raises and never corrupts the file — so it cannot fail the tool or the close.
API
MCP server is implemented in src/beadloom/services/mcp_server.py:
create_server(project_root)-- creates an MCP Server with registered handlers, auto-reindex, and two-level caching
Beadloom requires mcp >= 2.0. The 2.0 low-level API takes its handlers as constructor arguments (Server(on_list_tools=..., on_call_tool=...)) instead of the @server.list_tools() / @server.call_tool() decorators of 1.x, and the handlers exchange protocol result models (ListToolsResult, CallToolResult) rather than bare lists. One behavioural consequence is explicit here: 1.x wrapped an exception raised by a tool handler into an error result, while 2.0 hands it to the runner, so create_server classifies a dispatch failure (unknown tool, missing ref_id) itself and returns CallToolResult(is_error=True) — the agent gets a correctable in-band message instead of a protocol error.
_dispatch_tool(conn, name, args, project_root?, cache?, l2_cache?)-- routes calls to handlers with cache management_ensure_fresh_index(project_root, conn)-- auto-reindex if stale (compares file mtimes withlast_reindex_at)_is_index_stale(project_root, conn)-- check staleness by comparing graph/docs mtimes
Handler functions (sync, testable without MCP transport):
handle_get_context(conn, *, ref_id, depth=2, max_nodes=20, max_chunks=10, project_root=None)-- context bundle;project_rootis what lets the bundle carry recorded intent, and without it the bundle says intent was not checked rather than absenthandle_get_graph(conn, *, ref_id, depth=2)-- subgraphhandle_list_nodes(conn, kind=None)-- list nodeshandle_sync_check(conn, ref_id=None, project_root=None)-- sync status, including theincompletedocument-shape rows whenproject_rootis given (the required sections are resolved from that root, so they cannot be resolved without one)handle_get_status(conn)-- index statisticshandle_update_node(conn, project_root, *, ref_id, summary=None, source=None)-- update nodehandle_mark_synced(conn, project_root, *, ref_id)-- mark syncedhandle_search(conn, *, query, kind=None, limit=10)-- FTS5 searchhandle_why(conn, *, ref_id, depth=3)-- impact analysis with flattened upstream/downstreamhandle_diff(project_root, *, since="HEAD~1")-- graph diffhandle_lint(project_root, *, severity="all")-- architecture lint. Itssummarycarrieslayer_populations[]— the same five keyslint --format jsonstates per declared layer rule,rule,edge_kind,evaluated,totalandskipped_untagged— so an agent reading the counts reads the population they were taken over:357 of 365 live depends_on edge(s), measured on this repository on 2026-09-13, and365 of 365produce the sameerrors/warningspair. Additive to the three keys that were there, and deliberately OUTSIDE theseverityfilter, because a population is not a finding and a finding filter must not be able to hide it (BDL-070 A4)handle_get_debt_report(conn, project_root, *, trend=False, category=None)-- architecture debt report; the trend is attached withdataclasses.replace, which keepslayer_populationsandtest_population
Process-tool handlers (BDL-048; the three bead-touching ones drive bd via the services/bd_seam/client.py:run_bd seam):
handle_task_init(project_root, *, type_, key)-- scaffold docs folder + per-type skeletons + the 4-role bead DAG (dev → test → review → tech-writer), created as onebd_seam.creationplan in ONEbd create --graphprocess whose edges name role keys; the ids are read from bd's JSON answer withallocated_ids, never scraped from--silentstdout. The argv is spelled as a literal at the call site rather than composed by a helper, becausebd_seam.invocationsresolves a list handed torun_bdand cannot follow a function call — a tidier builder would make this creation site invisible to the derivation that judges ithandle_bead_context(project_root, *, bead)-- one payload: ctx + why + CONTEXT/ACTIVE excerpt + active rules (resolves the bead's graph ref frombd show, through the sameapplication.waves.declared_refsparser AND the sameapplication.waves.compose_declarationcomposerbeadloom wavesreads a bead's declared scope with, so the tool and the wave planner cannot come to disagree about what a bead said -- the composer matters as much as the parser, because this tool used to join the tracker's fields with a space where both CLI callers joined them with a newline, and a danglingrefs:header then adopted the next field's first word; the ALPHABETICALLY first declared ref is the one this tool builds a bundle for, since the parser returns them sorted)handle_complete_bead(project_root, *, bead, run_tests=True)-- the refusing gate:run_ci_gate(+ tests); PASS closes the bead (bd close --suggest-next), confirms the suggestion againstbd ready --limit 0throughbd_seam.answers.confirmed_suggestion, and best-effort flips its ACTIVE.md table row to✓ done; FAIL returns findings and does NOT close (table untouched); both verdicts carry the room they were taken in andnot_run, the verifications the project's pipeline declares that no step of the run performed (("tests",)is passed to the gate asperformed_elsewherewhen this handler runs the suite itself, so the two halves of one run cannot contradict each other); the FAIL verdict also carriesowners, which bead claimed now owns each finding it is refusing to close on (BdWorkTrackeris wired in from this layer, since thebdseam lives here and the application layer must not import it); advisory-strong (CI is the true gate)- The suite runner behind it (
_run_test_suite) reads pytest's output with a statedencoding="utf-8",errors="replace". It keeps only the summary line, and that line carries a test id, a path or an arrow often enough that inheriting the image's locale turned "the suite failed" into an unrelated decode error on a non-UTF-8 container;replacebecause the string is shown to an agent as prose, so a visible U+FFFD beats an exception (BDL-061.42) handle_checkpoint(project_root, *, bead, text, status="in progress")--bd comments add+ best-effort timestamped ACTIVE.md note + best-effort ACTIVE.md table row →status_set_active_table_status(active_path, bead_id, status)-- deterministic, tolerant markdown-table row updater (whole-token bead-id match in the first cell; replaces the last cell; no-op +Falseon missing file/table/row; never raises)
The bd seam lives in src/beadloom/services/bd_seam/client.py, re-exported from the package so from beadloom.services.bd_seam import run_bd keeps its meaning: run_bd(args, *, cwd=None) returns a BdResult(returncode, stdout, stderr) (with .ok), and raises BdUnavailableError when bd cannot be run to completion — missing from PATH, present but not executable, wedged past the 60 s timeout, or answering in bytes that cannot be decoded. The message always names the underlying class — bd could not be run to completion (TimeoutExpired: …) — and FileNotFoundError keeps its own wording because installing bd is the one remedy a reader can act on. A non-zero exit is not this case: that is bd answering, and it comes back as a BdResult. Output is decoded with an explicit utf-8 / surrogateescape codec rather than the ambient locale, so one non-UTF-8 byte in a bead title cannot turn a tool call into a crash (BDL-061.37). Tests patch this seam so the process-tools run without a real bd binary.
Setup and configuration commands are in src/beadloom/services/commands/setup.py. Only mcp_serve belongs to this service; the rest carry # beadloom:domain=onboarding and are specified in the onboarding domain. They share the module because they share the setup-* option surface, which is why a change to any of them makes both documents stale:
mcp_serve(*, project)-- launch the MCP server over stdio; exits 1 withRunbeadloom reindexfirstwhen the index is absentsetup_mcp(*, remove, tool_name, project)-- create, update or remove the MCP config for a supported editorsetup_rules(*, tool_name, project, refresh, dry_run)-- generate IDE adapter files;--refreshre-renders theCLAUDE.mdauto-regions and--dry-runpreviews without writingsetup_ai_techwriter(*, platform, project)-- scaffold the AI tech-writer harness forgithuborgitlabsetup_agentic_flow(*, project, force, tools, architecture, stack)-- compose and write the agentic dev flow; selection is flag overflow.ymlover defaultsetup_branch_protection(*, repo_slug, branch, contexts, dry_run)-- apply branch protection viagh api;--dry-runprints the call and payload without touching GitHubconfig_check(*, fix, project)-- report agent-config drift. Warnings print! <file>: <reason>with a-> <remediation>line and do not block; the command exits 1 only on error-severity drift, and a clean run saysAgent-config in sync — no blocking driftwith the warning count appended when there is one. Whenever the project declares a duty it also prints which corpus the duty check read — the composition, not the role files on disk — and how many role adapters exist there,NOTHING TO CHECKwhen none do (BDL-068 S4.x, BDL-UX #241)init(*, bootstrap, preset, import_path, init_mode, non_interactive, force, project)-- project initialization; also appends Beadloom's generated working set to the project's.gitignore, once. Four branches write a file under.beadloom/_graph/and all four end by running the Gate'slint_stepover the tree and exit 1 when it does not pass, so no branch reports success over a graph that fails the rules on disk (BDL-067, closing BDL-UX #192; the wizard was added in.6, having been missed while the covering tests counted the two bindings ofbootstrap_projectrather than the three branches). The enumeration is over branches that WRITE rather than over branches that bootstrap, since.17. Until then--importand--yes --mode importwere carved out — the first because it re-indexed nothing and told the adopter to runbeadloom reindexnext, the second because both of the verdict's headlines opened with the graph this command just wrote. Both reasons held only until the nextiniton the same tree: the wizard's re-init does not delete.beadloom/, so animported.ymlfrom an earlier run survived into a later bootstrap that wrotedomain-needs-parentand met its unparented nodes, and the report named a writer that had not run (the review of.16, major 2).--importnow re-indexes what it wrote before judging it.--yes --mode importstill cannot meet an adopter's rules file, because--yesreturnsskippedover an existing.beadloom/and--forcedeletes it. One carve-out remains named rather than implied: the wizard'seditreview answer, where the graph has just been handed to the user to edit by hand and nothing has re-indexed since. The wizard'scancelanswer looked like a second carve-out and was never declared as one:bootstrap_projectwritesservices.ymland leaves the adopter'srules.ymlalone BEFORE the graph review is asked, socancelleft both files on disk, exited 0 throughsys.exitand reported nothing — BDL-UX #192's sixth instance, on the branch a human adopter meets first (the review of.20, major 2). Since.21a run that wrote a graph file is judged however it ends:initcontains nosys.exit, and the verdict itself declines to lint when nothing under.beadloom/_graph/changed during the run, which is what keeps the OTHER cancelled answer — the re-init prompt, asked before any writer runs — correctly unjudged.--yes --mode bothis judged — its reindex was moved to the end ofnon_interactive_initin.14, because from inside the bootstrap block it produced an index that predated theimported.ymlthe same run went on to write, andinitreported clean over it while the adopter's nextlint --strictexited 1
Testing
Tests in tests/integration/services/mcp_server/test_mcp_server.py and tests/integration/services/mcp_server/test_mcp_new_tools.py verify each read/write handler directly (without MCP transport). TestMcpProtocolHandlers additionally drives the registered tools/list and tools/call entries the way the runner does — including the unknown-tool in-band error — so a protocol-layer change cannot pass on create_server merely returning an object. The process-tools are tested in tests/integration/services/mcp_server/test_mcp_process_tools.py (with the bd seam + gate mocked — complete_bead is asserted to REFUSE on a red gate and to close on green) and the seam itself in tests/integration/services/bd_seam/test_bd_seam.py. The package's other half — the derived population of bd call sites the seam is the boundary of — is in tests/unit/services/bd_seam/test_bd_call_sites.py, and what each answer that came back actually covers is in tests/unit/services/bd_seam/test_bd_answers.py; both are described in the bd Seam DOC. Their pins over this repository's own call sites are self-checks, in tests/self_check/process/test_bd_call_sites.py and tests/self_check/process/test_bd_answers.py.