Symptom

A krill-mcp client connected to a single seed (pi-krill-05.local) could not discover the swarm’s other peers (pi-krill.local). GET /health returned the seed’s own Server node serialized via ServerMetaData, with no peer field; the seed already had every peer in memory but did not surface any of them on that route. krill-mcp had no path to bootstrap transitive discovery from one seed without parsing the heavier /nodes payload.

Root cause

/health was framed as “this server’s own meta + non-Server child node ids” since 1d02ef4d5 simplified peer connections (2026-02-08), and explicitly filters KrillApp.Server-typed rows out of the nodes array (Routes.kt:851). The exclusion is correct for /health’s shape contract (stable single-server payload) but leaves no route at all for the swarm view.

The deeper invariant the route had to honour, which was undocumented in code and lived only in the Mesh Network agent prompt: every server stores other servers it has discovered as full KrillApp.Server rows alongside its own, and those rows must NOT be serialized as-is. The receiving app or peer server already knows that install as its own authoritative Server node, and a same-id Server payload would clobber it on ingest. The shared toPeer(node) helper (shared/.../node/SharedNodeFunctions.kt) is the canonical conversion: it flips type to KrillApp.Server.Peer, rewrites id to the composite {thisServerId}:{peerServerId}, and reparents under the responding server. Without that conversion, exposing the peer list would silently corrupt downstream state.

Fix

Added a sibling GET /peers route under authenticate("auth-api-key") in server/.../Routes.kt, leaving /health shape-stable per the issue’s recommended option (2). The route delegates to a new server/.../io/PeerProjection.kt helper:

1
2
3
4
fun peerProjection(nodes: List<Node>): List<Node> =
    nodes
        .filter { it.type == KrillApp.Server && !it.isMine() }
        .map(::toPeer)

Using == rather than is keeps the projection scoped to the top-level Server data object — child types under the Server sealed parent (Pin, LLM, SerialDevice, Backup, Peer) are out of scope for swarm enumeration. The !isMine() clause drops the local server. toPeer is the shared conversion documented above. Helper-level KDoc enumerates all three field rewrites (type, id, parent) so future readers don’t have to chase them across files.

Regression coverage in server/src/jvmTest/.../io/PeerProjectionTest.kt exercises three cases: mixed local/remote/child types (drop-rules), composite-id formation, and the empty-swarm degenerate. Tests guard against accidental relaxation of the type filter or id format — both of which would silently break consumer ingest rather than surface as a route error.

Prevention