Skip to content

Latest commit

 

History

History
769 lines (605 loc) · 28.2 KB

FEDERATION.md

File metadata and controls

769 lines (605 loc) · 28.2 KB

Federation Mode

Federation mode is the multi-repo mode of lain. Run it with:

lain server --config ./repos.yaml --transport http --port 9999
# Open http://localhost:9999 — the Command Center dashboard.

A federation knows about N repositories at once and answers org-wide questions across them: who defines a given function, who calls it, what other services depend on it, and which repos are degraded right now. Federation is the headline of lain: a single long-running process that owns the index, the workspaces, and the MCP tool surface.

flowchart LR
    subgraph FED["FederatedIndex"]
        MAP["RwLock<HashMap<RepoId, Arc<RepoIndex>>>"]
        GB["Arc&lt;dyn GraphBackend&gt;<br/>PetgraphBackend<br/>federated_graph.bin"]
        RES["resolve_symbol()<br/>symbol_to_repos index<br/>+ backend fallback"]
    end

    subgraph R1["RepoIndex bytes"]
        LSP1["LSP pool"]
        PG1["per-repo petgraph"]
        FW1["file watcher"]
    end

    subgraph R2["RepoIndex tokio"]
        LSP2["LSP pool"]
        PG2["per-repo petgraph"]
        FW2["file watcher"]
    end

    MAP --> R1
    MAP --> R2
    R1 -->|project_repo| GB
    R2 -->|project_repo| GB
    RES --> MAP
    RES --> GB
    GB --> TOOLS["federation_tools<br/>(6 MCP tools)"]
Loading

The diagram shows the two-tier model: each RepoIndex runs its own engines (LSP pool, petgraph, file watcher) on its own clone, and the FederatedIndex projects each worker's nodes into one global petgraph via project_repo(). The six federation MCP tools then read the global graph and bucket results by repo.

This document is the central reference for federation mode. The schema of the repos.yaml config lives in docs/REPOS_YAML.md; this doc covers the operating model, the MCP tools, the resolver rules, performance, and troubleshooting. The on-disk state format and the petgraph backend live in docs/TECHNICAL.md. Hot-reload of repos.yaml / workspaces.yaml is documented in docs/hot-reload.md; the Command Center dashboard is in docs/command-center.md.


When to use federation vs single-workspace

Mode Use it when
Single-workspace (lain server --config ./repos.yaml --workspace <name> with a one-member workspace) You want to scope answers to one repo's subgraph.
Federation (lain server --config repos.yaml) You have many repos and want to ask org-wide questions: "which repos define a function named verify_token?", "what does repo A's shared library affect in repo B and C?", "is every repo healthy right now?".

The federation tools are read-only and do not let you mutate the underlying graphs.


Setup

Federation mode is configured by a repos.yaml file and started by lain server. The schema is documented in docs/REPOS_YAML.md; this section is the operational quickstart.

sequenceDiagram
    autonumber
    participant Op as Operator
    participant CLI as lain repos add
    participant YAML as repos.yaml
    participant Sock as Unix socket
    participant Bus as ReloadBus
    participant Srv as lain server

    Op->>CLI: lain repos add bytes <url>
    CLI->>YAML: atomic write (temp + rename)
    CLI->>Sock: write "reload\n"
    Sock->>Bus: request_reload()
    Bus->>Srv: rebuild task
    Srv->>Srv: diff new vs live<br/>add/remove operations
    Srv-->>Op: list_repos() now shows bytes
Loading

The flow covers the most common operator action: a CLI write that reaches the running server without a restart. Hand-edits take the same path through the notify watcher.

1. Write a repos.yaml

At minimum, list the repos you want indexed:

data_dir: /var/lib/lain
repos:
  - id: bytes
    source: { type: workspace_dir, path: /srv/bytes }
  - id: tokio
    source: { type: local_clone, url: https://example.com/billing.git }
  - id: web
    source: { type: shallow_clone, url: https://example.com/web.git }

The two relevant bounding knobs (defaults shown):

Field Default Purpose
max_concurrent_indexers 8 Semaphore that caps how many repos index in parallel at startup.
ready_threshold 0.8 Fraction of repos that must reach Ready health before the federation reports healthy.

Each repo can be a workspace_dir (path on disk), local_clone (full clone into data_dir/<id>), or shallow_clone (shallow clone that refreshes on an interval). See docs/REPOS_YAML.md for the full schema and id rules.

2. Start the server

lain server --config /etc/lain/repos.yaml --transport http --port 9999

Flags (all have defaults):

  • --config <path> — required; path to repos.yaml
  • --transport <http|stdio> — default stdio (set to http for the Command Center dashboard)
  • --port <u16> — default 9999, only meaningful for HTTP
  • --log-level <env-filter> — default info; passed to tracing's EnvFilter
  • --workspace <name> — optional; pin to a workspace declared in workspaces.yaml. Use auto to read the operator's ~/.config/lain/active_workspace pointer.
  • --embedding-model <path> — optional; ONNX model for semantic_search
  • --no-process-attribution — disable /proc/<pid>/fd attribution on Linux/macOS

The loader reads the config, builds per-repo sources, and indexes each repo up to max_concurrent_indexers at a time. After loading, the server explicitly runs repo.index() on every registered repo so that freshly loaded federations have actual nodes (the load-time projection only sees whatever is already in the per-repo DB). A failed index() demotes that repo to Degraded and the federation still comes up so partial results remain queryable.

While the server runs, the Command Center dashboard is at GET / on the HTTP port. The status bar shows pid, transport, repo/workspace counts, and the reload state in real time. See docs/command-center.md.

3. Wait for it to be ready

The federation is considered ready when ready_threshold (default 80%) of the registered repos have health: "ready". Until then, individual repos will report indexing, degraded, unavailable, or missing. Watch the logs, the Command Center Overview tab, or poll get_federation_health over MCP.


Tools

Federation mode registers six read-only MCP tools. All tool results are returned as JSON text inside CallToolResult::content[0].text. Tool failures set is_error: true and put the error message in the text block.

The JSON shapes below are the exact serde-derived structs from src/mcp/federation_tools.rs.

list_repos

Returns every registered repo with its current health, path, last refresh/index timestamps, and node/edge counts.

  • Arguments: none
  • Returns: array of RepoInfo:
[
  {
    "id": "bytes",
    "path": "/srv/bytes",
    "health": "ready",
    "last_refreshed_unix": 1730000000,
    "last_indexed_unix": 1730000000,
    "node_count": 1234,
    "edge_count": 987
  }
]

Field reference (struct: RepoInfo):

Field Type Notes
id string The repo id from repos.yaml
path string Local on-disk path of the repo (the clone for local_clone / shallow_clone, the path for workspace_dir)
health string One of ready, indexing, degraded, unavailable, missing
last_refreshed_unix i64 Seconds since UNIX epoch when the source was last refreshed (e.g. shallow clone fetch)
last_indexed_unix i64 Seconds since UNIX epoch when RepoIndex::index() last completed
node_count usize Nodes in this repo's per-repo GraphDatabase
edge_count usize Edges in this repo's per-repo GraphDatabase

Errors: none — always returns the current list, even when empty.

get_repo_info

Returns the same RepoInfo payload as list_repos, but for a single repo by id.

  • Arguments:
    • id (string, required) — the repo id
  • Returns: a single RepoInfo object (same shape as above)
  • Errors:
    • Missing required argument: id — no id key in args
    • NotFound: repo <id> — no repo with that id is registered

get_federation_health

Aggregate counts per health bucket, plus total node/edge counts and a rough memory estimate.

  • Arguments: none
  • Returns: FederationHealth:
{
  "total_repos": 12,
  "ready": 10,
  "indexing": 1,
  "degraded": 0,
  "unavailable": 1,
  "missing": 0,
  "total_nodes": 123456,
  "total_edges": 78901,
  "memory_estimate_bytes": 32456700
}

The memory_estimate_bytes field is computed by the implementation as:

memory_estimate_bytes = total_nodes * 200 + total_edges * 100

It's a rough upper-bound heuristic, not a measured RSS.

Errors: none.

search_org

Case-insensitive substring search across every repo's symbols, matched on name or path. Results are deduplicated by global_id, sorted by (repo_id, name), and truncated to the caller-supplied limit.

  • Arguments:
    • query (string, required) — substring to match
    • limit (integer, required) — max number of results to return
  • Returns: array of SymbolMatch:
[
  {
    "global_id": "bytes:Function:src/auth.rs:verify_token",
    "repo_id": "bytes",
    "name": "verify_token",
    "path": "src/auth.rs",
    "kind": "function"
  }
]

Errors:

  • Missing required argument: query
  • Missing required argument: limit
  • Invalid argument: limit must be a non-negative integer — limit is the wrong type or unparseable

get_cross_repo_blast_radius

The headline federation tool. Resolves a symbol across the federation, then traverses incoming Calls edges in the federated graph — the symbol's callers, answering "if I change this, what breaks?" — and groups the visited nodes by repo. (The prose here previously said "outgoing" while the example below listed caller_* nodes; the example was the accurate half.)

  • Arguments:
    • symbol (string, required) — the function/symbol name
    • depth (string, required) — a half-open Range<u32> like "1..3" (start inclusive, end exclusive)
  • Returns: CrossRepoBlastRadius:
{
  "by_repo": {
    "bytes": [
      "bytes:Function:src/auth.rs:caller_a",
      "bytes:Function:src/auth.rs:caller_b"
    ],
    "tokio": [
      "tokio:Function:src/checkout.rs:caller_c"
    ]
  },
  "total_count": 3,
  "truncated": false
}

Implementation notes:

  • The traversal is outgoing-only along Calls edges from the seed.
  • Results are bucketed by the repo_id parsed out of each visited node's global id (repo_id:Kind:path:name).
  • A hard cap of 1000 nodes per call is enforced. When hit, truncated is true and total_count equals the cap. More nodes exist beyond it; the caller should narrow depth or pick a different seed.
  • The seed node itself is excluded by the depth lower bound (min_depth=1), so a symbol with no outgoing Calls edges yields an empty result, not an error.

Errors:

  • Missing required argument: symbol
  • Missing required argument: depth
  • Invalid depth: expected "<start>..<end>" or a number, got <input> — depth accepts the range string "1..3" or a number 3 (shorthand for 1..3); anything else is echoed with its type
  • NotFound: symbol <name> not found in any repo — resolve_symbol found nothing
  • AmbiguousSymbol: [...] — resolve_symbol found the symbol in multiple repos; re-call get_cross_repo_blast_radius with repo_id set to the repo that owns the seed
  • NotFound: symbol <name> not found in repo <id> — the given repo_id does not own that symbol

get_cross_repo_blast_radius_for_repo (dispatch alias)

Not advertised in tools/list — kept for callers that already use it. The primary path is get_cross_repo_blast_radius with repo_id set, which has identical semantics: the caller disambiguates the repo explicitly, bypassing resolve_symbol.

  • Arguments:
    • repo_id (string, required) — the repo id that owns the seed symbol
    • symbol (string, required) — the function/symbol name
    • depth (string, required) — Range<u32> like "1..3"
  • Returns: CrossRepoBlastRadius (same shape as above)
  • Errors:
    • Missing required argument: repo_id | symbol | depth
    • Invalid depth: expected "<start>..<end>", got "<input>"
    • NotFound: symbol <name> not found in repo <id>
    • Invalid repo id: <id> — bad repo id (raised by RepoId::new's validation: empty, contains :, or contains /)

Graph schema versioning and recovery

The federated graph on disk carries an explicit envelope: the LNF2 magic and a FEDERATION_GRAPH_VERSION (src/server/federation/graph_backend.rs). A graph written by another version — or a corrupt body under a valid header — is refused at load (FederationSchemaMismatch / FederationPayloadCorrupt), never silently migrated.

Recovery is always the same command:

lain reindex --config ./repos.yaml          # all workspaces
lain reindex --config ./repos.yaml --workspace backend-team

lain reindex rebuilds every repo's graph from source and writes one federated_graph.bin. The result is deterministic in content: two runs on the same tree produce the same nodes and edges. The file bytes are not identical between runs, because node-index order and the last_lsp_sync / last_git_sync timestamps vary. Compare two graphs by content, not by hash. The on-disk format is unchanged, so an existing graph does not need a reindex to load.

On a 3-repo Saleor federation (about 91k nodes, 232k edges), cold reindex takes about 30 s and peaks near 2.7 GB RSS (measured on current dev). The optional parse cache (parse_cache_mb in repos.yaml) is off by default; see docs/REPOS_YAML.md.

It backs the old graph up to federated_graph.bin.bak, removes stale sidecar state, and rebuilds every repo's per-repo graph plus the federation backend. See CHANGELOG.md for which releases bump the schema.


Contract federation (services, joins, snapshots)

When repos.yaml declares services: / http_clients: / bindings:, the federation derives a per-federation ContractIndex (PR 7) and exposes the 13 contracts-package tools (PR 13). See docs/REPOS_YAML.md for the config schema; the design lives in docs/superpowers/specs/2026-10-02-contract-coverage-and-protocols-design.md.

Services and monorepos

A service is a join unit: every contract node (HTTP route, OpenAPI schema, HTTP client call, field read) is routed to its service by the longest matching paths prefix under the service's repo. Monorepos declare one service per paths entry — platform in the fixture carries shipping and inventory as separate services that share the same repo.

Contract joins

The joiner runs once per FederatedIndex and produces both the desired Binds edge set and a fresh ContractIndex. It uses the repos.yaml config_hash (blake3 of the canonical JSON of the join-relevant sections) so two snapshots with different bindings: entries have different ids and diff_contracts distinguishes them.

Scoped "no known impact"

Every analysis result carries a Scope (§9.6) listing the reviewed and unreviewed repos. A NoKnownImpact verdict without an unreviewed list claiming the change is safe is the default; reports name every repo that could not be reviewed. An unresolved consumer in a reviewed repo is a concrete lead, so it makes the change NeedsInvestigation instead of being listed in the scope.

Snapshots — what they include and what they omit

Snapshot views are built over PetgraphBackend::ephemeral: every save() is a no-op, the in-memory GraphDatabase is fresh, and project_graph is shared with the live path so both produce identical node and edge sets from the same per-repo graph. What snapshots do not contain: cross-repo Calls and CrossRepoSameSymbol edges (§8.6), since the cache entries are built without other repos. Contract impact does not need them because services talk through Binds. Tools that depend on symbol-level joins (get_cross_repo_blast_radius and friends) keep working on live only.

Tool surface

The 13 tools (list_services, get_service, prepare_snapshot, get_snapshot, list_contracts, get_contract, list_unresolved, check_binding, diff_contracts, trace_impact, get_coverage, resolve_evidence, read_source) follow §10.2 envelope, §10.4 determinism (byte-identical structuredContent across stdio and HTTP for the same view), §10.5 paging/limits, and the §13 error codes. See docs/quickstart-tools.md for a walkthrough.


Tool resolution rules

Federation tools that take a repo_id (currently get_repo_info, get_cross_repo_blast_radius / its _for_repo alias, and the per-repo tools in single-workspace mode that are resolved against a federation) use resolve_repo_for_tool in src/mcp/handler.rs. The rule, in order:

flowchart TB
    A["args received"] --> B{"repo_id present<br/>and valid?"}
    B -->|yes| R1["use repo_id"]
    B -->|no| C{"symbol present?"}
    C -->|yes| D["resolve_symbol(symbol)"]
    D -->|0 repos| E["NotFound"]
    D -->|1 repo| R2["that repo"]
    D -->|>1 repos| F["AmbiguousSymbol"]
    C -->|no| G{"0 repos registered?"}
    G -->|yes| H["Config: no repos registered"]
    G -->|no| I{"exactly 1 repo?"}
    I -->|yes| R3["that repo"]
    I -->|no| J["Config: multiple repos<br/>specify repo_id or symbol"]
Loading
  1. Explicit repo_id. If the call's args.repo_id is present and non-empty, use it as-is. If it's malformed (fails RepoId::new's validation), return Invalid repo id: <id>.
  2. Symbol hint. Otherwise, if args.symbol is present, call FederatedIndex::resolve_symbol(symbol):
    • exactly one repo owns that name → return that repo
    • zero repos own it → return NotFound: symbol <name> not found in any repo
    • multiple repos own it → return AmbiguousSymbol(candidates) so the caller can present candidates to the user
  3. Zero repos. If neither arg is present and zero repos are registered, return Config: no repos registered.
  4. Single-repo fallback. If neither arg is present and exactly one repo is registered, use that repo.
  5. Multi-repo dead end. If neither arg is present and multiple repos are registered, return Config: multiple repos; specify repo_id or symbol.

Examples (in args):

args Resolution
{ "repo_id": "bytes" } Step 1 → bytes
{ "symbol": "verify_token" }, unique owner Step 2 → owning repo
{ "symbol": "verify_token" }, multiple owners Step 2 → AmbiguousSymbol
{ "symbol": "nope" }, no owners Step 2 → NotFound
{}, zero repos Step 3 → Config: no repos registered
{}, one repo Step 4 → that repo
{}, multiple repos Step 5 → Config: multiple repos; specify repo_id or symbol

When the resolver returns AmbiguousSymbol, the MCP layer surfaces a JSON payload of shape {"error":"ambiguous_symbol","candidates":[...], "message":"..."} inside the tool result text so the agent can present the candidates and pick one for the next call.


Performance

flowchart LR
    Q["MCP tool call"] --> RI["resolve_symbol<br/>O(1) lookup"]
    RI --> T["GraphBackend::traverse<br/>BFS, depth-bounded"]
    T --> CAP["cap at 1000 nodes"]
    CAP --> OUT["CrossRepoBlastRadius"]
Loading

The federation has two measured workloads; both are in tests/federation_benchmark.rs.

Small fixture — p99 < 100ms

The small fixture is 10 repos × 5,000 nodes per repo = 50,000 total nodes, with each repo holding a chain of Calls edges. The test runs 100 get_cross_repo_blast_radius calls (depth 1..5, walking a 5-hop chain) and asserts the 99th-percentile latency is under 100 ms.

It runs on every PR:

cargo test --features test-utils --test federation_benchmark \
    small_fixture_blast_radius_under_100ms_p99 -- --nocapture

Large fixture — 200 repos / 10M nodes

The large fixture is 200 repos × 50,000 nodes per repo = 10,000,000 total nodes. It also asserts memory_estimate_bytes < 32 GB. It is marked #[ignore] (nightly-gated) and is not part of the per-PR suite:

cargo test --features test-utils --test federation_benchmark \
    large_fixture -- --ignored --nocapture

Memory estimate formula

get_federation_health.memory_estimate_bytes is computed by the implementation as:

memory_estimate_bytes = total_nodes * 200 + total_edges * 100

This is a rough upper bound — actual RSS will depend on the data_dir layout and OS page cache. Use it as a sanity check, not as a precise measurement.

Throughput caveats

  • The per-PR latency budget (100 ms p99) is measured against the small fixture only. Larger federations will be slower; the cost scales with the size of the reachable subgraph at the requested depth.
  • The blast-radius traversal is bounded by a hard cap of 1000 nodes per call. Deep traversals over a dense subgraph will hit this cap and set truncated: true.
  • Cross-repo symbol matching (find_cross_repo_matches, called from project_repo) is O(repos × nodes) per repo projection and runs once at load time. Cold start of a large federation is dominated by indexing; subsequent restarts pay only the re-projection cost.

Troubleshooting

Common errors

Error Cause Remediation
NotFound: symbol <name> not found... Repo still indexing or symbol absent Verify list_repos health is ready; check short name
AmbiguousSymbol Multiple repos own identical function name Disambiguate by supplying repo_id from error candidates
NotFound: repo <id> / Invalid repo id Id is unregistered or invalid (empty, contains :, /) Check list_repos for canonical spelling
NotFound: symbol <name> in repo <id> Specified repo does not define the symbol Use search_org to locate owning repo
Config: no repos registered / multiple repos Empty config or missing required repo_id/symbol Check repos.yaml or provide disambiguating argument
Config: yaml: ... Configuration parsing error Check indentation, id, and source.type syntax
Invalid argument: ... / Invalid depth: ... Malformed limit or depth (<start>..<end>) Format depth as "1..3" (exclusive end); limit as positive integer
truncated: true Traversal hit 1000-node cap Decrease depth or use search_org first

Repository health states

  • indexing: initial state; transitions to ready upon completion.
  • degraded: index() failed; check server logs for language server binary or permission issues.
  • unavailable: source fetch() failed (e.g., unreachable remote).
  • missing: expected repository data directory is missing.

Git sensor & sidecar troubleshooting

Git operations run via the lain-git-sidecar child daemon by default (2 s IPC timeout, automatic bounded recovery).

  • Check get_health or get_capabilities: sidecar telemetry reports alive, child_pid, and failure metrics.
  • Set LAIN_GIT_SENSOR=in_process only when diagnosing sidecar-specific issues.
  • For deep diagnosis of wedged processes, use scripts/debug-hung-server.sh <repos.yaml>.

Workspaces

Workspaces are named groups of repos that the federation engine indexes together as a coherent unit. A workspace = a subset of repos.yaml's repos, picked at server start via --workspace <name> (or auto to read the operator's ~/.config/lain/active_workspace pointer). The workspace config lives in workspaces.yaml next to repos.yaml.

flowchart LR
    subgraph Y["repos.yaml"]
        R1[bytes]
        R2[tokio]
        R3[db-client]
        R4[web]
    end

    subgraph W["workspaces.yaml"]
        WS1["backend-team<br/>members: [bytes, tokio, db-client]"]
        WS2["payments-ws<br/>members: [tokio, db-client]"]
    end

    R1 --> WS1
    R2 --> WS1
    R2 --> WS2
    R3 --> WS1
    R3 --> WS2

    WS1 --> ACT["active workspace<br/>(~/.config/lain/active_workspace)"]
    ACT --> SRV["--workspace auto → federation loads only WS1's repos"]
Loading

When to use workspaces

Mode Use it when
Federation (lain server --config repos.yaml) Org-wide questions across all repos
Workspace (lain server --config repos.yaml --workspace <name>) Questions scoped to a named subset ("backend-team", "payments-ws")

Setup

  1. Declare workspaces in workspaces.yaml (same directory as repos.yaml):
    workspaces:
      - name: backend-team
        members: [bytes, tokio, db-client]
  2. Pick one: lain workspaces use backend-team (writes ~/.config/lain/active_workspace).
  3. Start the server: lain server --config repos.yaml --workspace auto --transport http --port 9999. The federation loads only backend-team's members. All 6 federation tools operate scoped.
  4. Workspace switching is restart-only — workspaces.yaml edits are hot-reloaded (see docs/hot-reload.md) but the active workspace is read once at startup.

Workspace CLI

lain workspaces create / list / show / use / current / forget

See lain workspaces --help.

MCP tools (workspace mode)

In addition to the 6 federation tools (scoped to the workspace's repos):

  • list_workspaces — all known workspaces + which is active
  • get_active_workspace — the active workspace's name + members
  • get_workspace(name) — full detail on one workspace

Command Center

When lain server --transport http is running, the Command Center at GET / shows:

  • Active workspace panel (name + members + their paths/healths)
  • Config panel (paths + repo counts)
  • Per-workspace D3 force-directed graph view (Functions/Methods/Classes, Topic hubs and their Produces/Consumes legs, + Calls/Imports, color by repo_id, dashed lines for cross-repo Calls)

See docs/command-center.md.

Agents

get_agent_strategy (a built-in MCP tool) includes a "Workspace mode" section that documents the workspace tools + the repo_id resolution rule when scoped.

Migration

Federation mode is additive on top of single-workspace mode.

Single-workspace users

Run the same directory under a one-member repos.yaml and start the server with lain server --config ./repos.yaml --transport stdio. Federation-only tools (list_repos, get_federation_health, etc.) still work; per-repo tools (query, coupling, blast_radius, etc.) work via the workspace's single repo.

Federation users coming from single-workspace

Switching modes is opt-in. To turn a single-repo deployment into a federation, write a repos.yaml (see docs/REPOS_YAML.md) and start the server with lain server --config repos.yaml. The existing single-workspace CLI does not need to change.

/health endpoint

The HTTP /health endpoint is shared between modes. Federation mode attaches a federation field describing per-repo health, total counts, and the memory estimate; single-workspace mode reports federation: null. The rest of the /health payload is unchanged.


Smoke test

The block below starts the federation server in the background, waits for it to come up, and exercises two federation tools via the MCP HTTP JSON-RPC endpoint.

# Start the federation server:
lain server --config repos.yaml --transport http --port 9999 &

# Wait for it to be ready:
sleep 30

# List repos:
curl -s -X POST http://localhost:9999/mcp -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"list_repos","arguments":{}},"id":1}' \
  | jq '.result.content[0].text | fromjson'

# Search org-wide:
curl -s -X POST http://localhost:9999/mcp -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"search_org","arguments":{"query":"verify","limit":5}},"id":1}' \
  | jq '.result.content[0].text | fromjson'

The sleep 30 is a rough budget for the federation to reach ready_threshold of registered repos. Replace it with a poll on get_federation_health when automating. Federation tool results are JSON text wrapped inside CallToolResult::content[0].text; the jq filter unwraps the inner JSON so the output is directly readable.

The full bash block above is shell-syntax-validated with:

awk '/^```bash$/,/^```$/' docs/FEDERATION.md | bash -n