Skip to content

Service

The read-only surface: a FastAPI app, an MCP server over stdio and streamable HTTP, and the registry both dispatch through (ADR-0014).

Neither transport defines an operation of its own. Both render TOOLS, and a parity test on each asserts its registered operations are exactly that tuple — so an operation added directly to a router or an MCP server, bypassing the registry, fails rather than ships. That is the mechanism by which "Phase 4 is read-only" is enforced rather than asserted: ToolSpec.side_effect is a closed Literal with one member, and widening it is a change mypy --strict blocks until someone makes it visibly.

There is no authentication, on either transport. Two controls stand in its place and they stop different callers: the bind address decides who can reach the port at all, and Host validation (ADR-0024) decides which requests are answered once they arrive — the second exists because a browser can be walked onto a loopback socket by DNS rebinding without its owner ever routing to it. Neither is authentication, and a deliberately published bind (--allow-remote-access) turns the second off by design. See Security and Known limitations for what that leaves open, the deployment guide for choosing a bind, and the MCP client guide for how to connect a client.

Operations

groundkit.service.tools

Transport-agnostic operations and the registry that declares them (ADR-0014).

Both surfaces — FastAPI and MCP — dispatch through :data:TOOLS. Neither defines an operation of its own, and that is the mechanism by which "Phase 4 is read-only" is enforced rather than asserted: :attr:ToolSpec.side_effect is a closed Literal with exactly one member, so adding a mutating operation requires widening that literal, which mypy --strict blocks until someone does it deliberately and visibly in the diff. Parity tests on each transport then assert that its registered operations are exactly this tuple, so an operation added directly to a router or an MCP server — bypassing the registry — fails rather than shipping.

The handlers here reach collections only through :class:~groundkit.runtime.CollectionRuntime, whose public surface is search-by-acquire plus reads. There is no path from this module to upsert_document, add_chunks, replace_document, delete_document or write_manifest, and an import scan over this package asserts that no service module reaches the ingest path at all.

SPEC.md §1.2 names the four operations. Ingest is not among them; that is the cited authority, so a later reader finds a decision rather than an omission.

ServiceContext dataclass

ServiceContext(
    registry: CollectionRegistry,
    index_dir: Path,
    base_dir: Path,
    reranker: RerankerProtocol | None = None,
    default_top_k: int = 5,
    scan_limiter: Semaphore = _new_scan_limiter(),
)

Everything a handler may reach. Assembled at serve time, never per request.

This object is ADR-0014 decision 6's guarantee. Provider settings, the index directory and the containment root arrive here from serve-time resolution, so no request model needs to carry them and no handler has a path to them other than this. The constraint holds by construction; the schema test that asserts no request field is named base_url or embed_* exists because a structural argument decays as a surface grows.

Attributes:

Name Type Description
registry CollectionRegistry

Collection registry; the only route to a retriever.

index_dir Path

Directory holding collections, for enumeration.

base_dir Path

Containment root every citation must resolve within.

reranker RerankerProtocol | None

Cross-encoder, or None when the server was started without --rerank. None is a refusal, never a passthrough.

default_top_k int

Applied when a request omits top_k.

scan_limiter Semaphore

Bounds concurrent corpus-scale work to :data:MAX_CONCURRENT_CORPUS_SCANS. Shared by both transports because they share these handlers, which is the point: two transports over one runtime must contend for one budget, not two.

ToolSpec dataclass

ToolSpec(
    name: str,
    summary: str,
    request_model: type[BaseModel],
    handler: ToolHandler,
    side_effect: SideEffect,
    rest_path: str,
    rest_method: Literal["GET", "POST"],
)

One operation, declared once and rendered by both transports.

Attributes:

Name Type Description
name str

MCP tool name, and the identity both parity tests compare on.

summary str

One-line description, surfaced to MCP clients.

request_model type[BaseModel]

Pydantic model the transport parses into. The MCP input schema is generated from this via model_json_schema() rather than hand-written a second time — a second schema is exactly the seam drift ADR-0001 hazard 4 exists to catch.

handler ToolHandler

Coroutine performing the operation.

side_effect SideEffect

Always "read_only" in Phase 4; see :data:SideEffect.

rest_path str

Path the REST surface mounts this on.

rest_method Literal['GET', 'POST']

HTTP method for the REST mirror.

handle_fetch_chunk async

handle_fetch_chunk(
    ctx: ServiceContext, request: FetchChunkRequest
) -> ChunkFetchResponse

Return one chunk with its citation re-verified against the source.

Verification is a byte comparison against text read back from disk, which makes it strictly stronger than resolve_citation's length-only drift check — it catches the same-length in-place edit KNOWN_LIMITATIONS.md records as undetectable — but only at this one boundary. search does not verify per hit, because that is one file read per result per query; this is the step a client performs on a hit it intends to quote.

Source code in src/groundkit/service/tools.py
async def handle_fetch_chunk(ctx: ServiceContext, request: FetchChunkRequest) -> ChunkFetchResponse:
    """Return one chunk with its citation re-verified against the source.

    Verification is a byte comparison against text read back from disk, which
    makes it strictly stronger than ``resolve_citation``'s length-only drift
    check — it catches the same-length in-place edit ``KNOWN_LIMITATIONS.md``
    records as undetectable — but only at this one boundary. ``search`` does
    not verify per hit, because that is one file read per result per query;
    this is the step a client performs on a hit it intends to quote.
    """
    async with ctx.registry.acquire(request.collection) as runtime:
        chunk = await runtime.get_chunk(request.chunk_id)
        # Keyed, and conditional: this used to read every row of ``documents``
        # unconditionally — before even establishing the chunk exists — to
        # look up the one document that chunk belongs to (GK-019). A request
        # naming an unknown chunk now performs no document read at all.
        record = None if chunk is None else await runtime.get_document_record(chunk.document_id)

    if chunk is None:
        raise ConfigurationError(
            f"no chunk {request.chunk_id!r} in collection {request.collection!r}"
        )

    if record is None:
        # The dangling-document case Retriever.search already fails closed on.
        raise RetrievalError(
            f"chunk {chunk.chunk_id!r} belongs to document {chunk.document_id!r}, "
            "which has no stored source — the index is inconsistent"
        )

    # A ``DocumentRecord`` rather than ``get_document_sources``' bare string
    # (ADR-0016).
    # A bare source string would leave this Citation at its ``("text", None)``
    # field defaults no matter what the document was ingested as, and the
    # consequences are not cosmetic: ``search`` would report a chunk's citation
    # as ``extracted`` while ``fetch_chunk`` reported the same chunk as
    # ``text`` — two read-only tools disagreeing about one stored fact — and
    # ``resolve_citation`` would take the plain read-and-slice branch instead of
    # refusing. For an extracted source that can "verify" against raw bytes the
    # offsets were never measured against; for a snapshot source it hands a URL
    # to ``ensure_within_base``, which is exactly the relative-path confusion
    # ADR-0016 decision 4 closes by classifying before resolving.
    citation = Citation(
        document_id=chunk.document_id,
        chunk_id=chunk.chunk_id,
        source=record.source,
        source_class=record.source_class,
        extractor=record.extractor,
        start_offset=chunk.start_offset,
        end_offset=chunk.end_offset,
    )

    # The per-collection snapshot containment root (ADR-0016 decision 4,
    # spec §10.1) — computed here rather than recomputed by resolve_citation
    # itself, mirroring how ctx.base_dir is already resolved once by the
    # caller. Harmless to compute unconditionally even for a non-snapshot
    # citation: resolve_citation only ever reads it on the "snapshot" branch.
    snapshot_dir = snapshots.snapshot_dir_for(ctx.index_dir, request.collection)

    verification: VerificationVerdict
    content: str | None = None
    detail: str | None = None
    try:
        resolved = await resolve_citation(citation, ctx.base_dir, snapshot_dir=snapshot_dir)
    except RetrievalError as exc:
        # resolve_citation sets `verdict` explicitly at every one of its raise
        # sites (ADR-0016 decision 6), so this reads a typed attribute instead
        # of pattern-matching the message text the way an earlier version did.
        # The `is None` fallback is defensive, not load-bearing: every path
        # inside resolve_citation now sets a verdict, so it should never
        # trigger. It exists so a future RetrievalError raised there without
        # one fails toward the more conservative verdict — `unresolvable`
        # never claims a definite "the source changed", `drifted` would.
        verification = exc.verdict if exc.verdict is not None else "unresolvable"
        detail = str(exc)
    else:
        if resolved == chunk.content:
            verification = "verified"
            content = resolved
        else:
            verification = "drifted"
            detail = "source text at the cited offsets no longer matches the indexed chunk"

    return ChunkFetchResponse(
        chunk_id=chunk.chunk_id,
        document_id=chunk.document_id,
        chunk_index=chunk.chunk_index,
        citation=citation,
        verification=verification,
        content=content,
        detail=detail,
    )

handle_index_status async

handle_index_status(
    ctx: ServiceContext, request: IndexStatusRequest
) -> IndexStatusResponse

Report one collection's size and embedding identity.

Counts and identity only. No document sources, no chunk content, no directory paths: SPEC.md §7 records that SQLite here is content-bearing data, so enumerating sources would disclose corpus layout to an unauthenticated reader.

Both counts are aggregates. Each was once a len() over a materialized table — the chunk half pulling every chunk's full text, the document half every source string — to produce two integers for the cheapest-looking call on this surface. Bounded by :data:MAX_CONCURRENT_CORPUS_SCANS even so: an aggregate over every row is still a full table scan, and this handler is reachable unauthenticated.

It also reports the runtime's rebuild counters (ADR-0026). This is the one operation on the surface that already holds the runtime and already reports the staleness marker, so it is where the marker's cost belongs too. The read is free — four attributes off the object already checked out, no store round-trip — and it is deliberately not an acquire: this handler never builds a retriever, so reading the counters here cannot move them.

Source code in src/groundkit/service/tools.py
async def handle_index_status(
    ctx: ServiceContext, request: IndexStatusRequest
) -> IndexStatusResponse:
    """Report one collection's size and embedding identity.

    Counts and identity only. No document sources, no chunk content, no
    directory paths: SPEC.md §7 records that SQLite here is content-bearing
    data, so enumerating sources would disclose corpus layout to an
    unauthenticated reader.

    Both counts are aggregates. Each was once a ``len()`` over a materialized
    table — the chunk half pulling every chunk's full text, the document half
    every source string — to produce two integers for the cheapest-*looking*
    call on this surface. Bounded by :data:`MAX_CONCURRENT_CORPUS_SCANS` even
    so: an aggregate over every row is still a full table scan, and this
    handler is reachable unauthenticated.

    It also reports the runtime's rebuild counters (ADR-0026). This is the one
    operation on the surface that already holds the runtime and already
    reports the staleness marker, so it is where the marker's *cost* belongs
    too. The read is free — four attributes off the object already checked
    out, no store round-trip — and it is deliberately **not** an acquire: this
    handler never builds a retriever, so reading the counters here cannot move
    them.
    """
    async with ctx.scan_limiter, ctx.registry.acquire(request.collection) as runtime:
        document_count = await runtime.document_count()
        chunk_count = await runtime.chunk_count()
        manifest = await runtime.get_manifest()
        generation = await runtime.get_generation()
        stats = runtime.rebuild_stats()

    return IndexStatusResponse(
        collection=request.collection,
        document_count=document_count,
        chunk_count=chunk_count,
        embedding=manifest,
        # Read from the same manifest the refusal in Retriever.search reads, so
        # a client cannot be told "available" by one path and refused by
        # another (ADR-0008).
        dense_search_available=manifest is not None,
        generation=generation,
        cache_enabled=generation is not None,
        schema_version=SCHEMA_VERSION,
        retriever_acquires=stats.acquires,
        retriever_rebuilds=stats.rebuilds,
        rebuild_seconds_total=stats.rebuild_seconds_total,
        last_rebuild_seconds=stats.last_rebuild_seconds,
    )

handle_list_collections async

handle_list_collections(
    ctx: ServiceContext, request: ListCollectionsRequest
) -> list[str]

Return the names of collections in the index directory.

Enumeration writes nothing. SQLiteMetadataStore.open applies the schema and stamps PRAGMAs, so confirming a candidate by opening it would make listing a write — which is why each candidate is checked with :func:~groundkit.index.metadata.is_groundkit_store instead, a read-only (mode=ro) identity probe.

That check is not cosmetic. Advertising every *.sqlite3 meant an unrelated database a user happened to leave here was reported as a collection, and a caller who then asked index_status for it had groundkit's four tables written into it. Filtering here and refusing in open close the two halves of that.

Each candidate is checked for containment before its name is reported, so a planted symlink (evil.sqlite3 -> /etc/passwd) resolves outside the base and is skipped rather than advertised. A non-conforming entry is skipped with a debug log, not an error: a hand-placed file in the index directory is not a groundkit collection, and refusing to list anything because of one would be a denial of the whole operation.

The whole scan runs in one :func:asyncio.to_thread hop. Every step of it blocks: glob and is_within_base hit the filesystem, and is_groundkit_store opens a real sqlite3 connection and reads a pragma per candidate. Run inline, that stalled the single event loop for N sequential sqlite opens -- in the tool an MCP or REST client typically calls first, so the stall lands on every other in-flight request. One hop rather than one per candidate because the thread-switch cost would otherwise dominate a scan whose individual steps are each tiny.

Source code in src/groundkit/service/tools.py
async def handle_list_collections(
    ctx: ServiceContext, request: ListCollectionsRequest
) -> list[str]:
    """Return the names of collections in the index directory.

    Enumeration writes nothing. ``SQLiteMetadataStore.open`` applies the schema
    and stamps PRAGMAs, so confirming a candidate by *opening* it would make
    listing a write — which is why each candidate is checked with
    :func:`~groundkit.index.metadata.is_groundkit_store` instead, a read-only
    (``mode=ro``) identity probe.

    That check is not cosmetic. Advertising every ``*.sqlite3`` meant an
    unrelated database a user happened to leave here was reported as a
    collection, and a caller who then asked ``index_status`` for it had
    groundkit's four tables written into it. Filtering here and refusing in
    ``open`` close the two halves of that.

    Each candidate is checked for containment before its name is reported, so a
    planted symlink (``evil.sqlite3 -> /etc/passwd``) resolves outside the base
    and is skipped rather than advertised. A non-conforming entry is skipped
    with a debug log, not an error: a hand-placed file in the index directory
    is not a groundkit collection, and refusing to list anything because of one
    would be a denial of the whole operation.

    The whole scan runs in **one** :func:`asyncio.to_thread` hop. Every step of
    it blocks: ``glob`` and ``is_within_base`` hit the filesystem, and
    ``is_groundkit_store`` opens a real ``sqlite3`` connection and reads a
    pragma per candidate. Run inline, that stalled the single event loop for N
    sequential sqlite opens -- in the tool an MCP or REST client typically
    calls *first*, so the stall lands on every other in-flight request. One hop
    rather than one per candidate because the thread-switch cost would
    otherwise dominate a scan whose individual steps are each tiny.
    """
    del request  # the operation takes no parameters; the signature is uniform

    def _enumerate() -> list[str]:
        if not ctx.index_dir.is_dir():
            return []

        names: list[str] = []
        for path in sorted(ctx.index_dir.glob("*.sqlite3")):
            if not is_within_base(str(path), ctx.index_dir):
                logger.debug("Skipping %s: resolves outside the index directory", path.name)
                continue
            if not is_groundkit_store(path):
                logger.debug("Skipping %s: not a groundkit collection", path.name)
                continue
            names.append(path.stem)
        return names

    return await asyncio.to_thread(_enumerate)
handle_search(
    ctx: ServiceContext, request: SearchRequest
) -> SearchResponse

Search one collection, optionally reranking the result.

Rerank is a per-request option, not a fourth SearchMode — ADR-0012 decision 2 deferred exactly this question to the service boundary, and the answer keeps SearchMode the three-way literal ADR-0007 settled. It reorders what Retriever.search returned; retrieval/search.py is untouched.

When rerank is requested on a server started without one, this raises :class:~groundkit.errors.RerankerNotConfiguredError rather than returning an unreranked list. That distinction is the whole reason the error type exists: a reranker that quietly returns what it was given is indistinguishable from one that worked, so a misconfigured deployment would report results a caller believes were reranked.

Candidate depth is pinned to MAX_TOP_K and is not a request field, for ADR-0012 decision 1a's reason — depth decides whether the ranking could move at all, so a per-request knob makes two responses silently incomparable in the dimension a reader is least likely to check.

Source code in src/groundkit/service/tools.py
async def handle_search(ctx: ServiceContext, request: SearchRequest) -> SearchResponse:
    """Search one collection, optionally reranking the result.

    Rerank is a per-request option, not a fourth ``SearchMode`` — ADR-0012
    decision 2 deferred exactly this question to the service boundary, and the
    answer keeps ``SearchMode`` the three-way literal ADR-0007 settled. It
    reorders what ``Retriever.search`` returned; ``retrieval/search.py`` is
    untouched.

    When ``rerank`` is requested on a server started without one, this raises
    :class:`~groundkit.errors.RerankerNotConfiguredError` rather than returning
    an unreranked list. That distinction is the whole reason the error type
    exists: a reranker that quietly returns what it was given is
    indistinguishable from one that worked, so a misconfigured deployment would
    report results a caller believes were reranked.

    Candidate depth is pinned to ``MAX_TOP_K`` and is not a request field, for
    ADR-0012 decision 1a's reason — depth decides whether the ranking could
    move at all, so a per-request knob makes two responses silently
    incomparable in the dimension a reader is least likely to check.
    """
    # Bound only when the request actually asked for it, so the `is None`
    # check below means "not reranking" rather than "no reranker configured" —
    # the two are different states and conflating them is how a server with a
    # reranker starts reranking requests that did not ask.
    reranker: RerankerProtocol | None = None
    if request.rerank:
        if ctx.reranker is None:
            raise RerankerNotConfiguredError(
                "rerank was requested but this server was started without a reranker. "
                "Restart with --rerank (and install the 'rerank' extra). Returning an "
                "unreranked result instead would be indistinguishable from a working one."
            )
        reranker = ctx.reranker

    top_k = request.top_k if request.top_k is not None else ctx.default_top_k
    fetch_k = MAX_TOP_K if request.rerank else top_k

    # Held across the retriever acquisition too, not just the search: a
    # rebuild is itself the O(corpus) BM25 reconstruction (ADR-0002), so
    # bounding the search alone would leave the more expensive half unbounded.
    async with ctx.scan_limiter, ctx.registry.acquire(request.collection) as runtime:
        acquired = await runtime.acquire()
        response = await acquired.retriever.search(request.query, top_k=fetch_k, mode=request.mode)

    if reranker is None:
        return response.model_copy(update={"metadata": {**response.metadata, "reranked": False}})

    reranked = await reranker.rerank(request.query, list(response.results), top_k=top_k)
    metadata = {
        **response.metadata,
        "reranked": True,
        "rerank_model": getattr(reranker, "model_name", type(reranker).__name__),
        # Recorded because a reranked hybrid result is NOT hybrid@top_k
        # reordered: RRF is not depth-invariant, so the set fused at this depth
        # differs in membership from the one at top_k (ADR-0012 decision 1).
        # Without this a client cannot tell which experiment it received.
        "rerank_input_depth": fetch_k,
    }
    return response.model_copy(
        update={"results": reranked, "total_results": len(reranked), "metadata": metadata}
    )

result_count

result_count(result: ToolResult) -> int

Number of items an access log reports for result.

A single-object read counts as one: the field exists so an operator can see "this search matched nothing" in the log without the query being in it, and reporting 0 for a successful fetch_chunk would make that reading wrong.

Lives here, beside :data:ToolResult, because both transports need it and neither may import the other. api.py deliberately does not import mcp_server, and the reverse would pull FastAPI into the stdio MCP path for the sake of one isinstance chain. A shared seam with a shared type is the right home; a second copy in the other transport is how the two access logs would drift into disagreeing about what a "result" is.

Parameters:

Name Type Description Default
result ToolResult

Whatever a handler returned.

required

Returns:

Type Description
int

The count an access log should report.

Source code in src/groundkit/service/tools.py
def result_count(result: ToolResult) -> int:
    """Number of items an access log reports for ``result``.

    A single-object read counts as one: the field exists so an operator can
    see "this search matched nothing" in the log without the query being in
    it, and reporting ``0`` for a successful ``fetch_chunk`` would make that
    reading wrong.

    Lives here, beside :data:`ToolResult`, because **both** transports need
    it and neither may import the other. ``api.py`` deliberately does not
    import ``mcp_server``, and the reverse would pull FastAPI into the stdio
    MCP path for the sake of one ``isinstance`` chain. A shared seam with a
    shared type is the right home; a second copy in the other transport is
    how the two access logs would drift into disagreeing about what a
    "result" is.

    Args:
        result: Whatever a handler returned.

    Returns:
        The count an access log should report.
    """
    if isinstance(result, SearchResponse):
        return len(result.results)
    if isinstance(result, list):
        return len(result)
    return 1

Request and response models

groundkit.service.schemas

Request and response models for the read-only service surface (ADR-0014).

Two rules govern this module, and both are load-bearing rather than stylistic.

Reuse contracts.py wherever it already models the domain. search returns the existing :class:~groundkit.contracts.SearchResponse unchanged, so a client parsing grk search --json and one parsing the REST route parse the same shape. Exactly two response wrappers are new, each because no contract covers what it carries — see their docstrings, which record why rather than asserting that it was necessary.

No request model may carry provider or filesystem configuration. There is no base_url, index_dir, base_dir, or embed_* field anywhere in this module, and there must never be one: the service resolves all of that at serve time, so a caller cannot reach it (ADR-0014 decision 6). Every model here is frozen with extra="forbid", so an unknown field is a rejection rather than a silently ignored one — the difference between a caller learning their request was misunderstood and a caller believing a setting took effect.

ChunkFetchResponse

Bases: BaseModel

A chunk, its citation, and whether that citation still holds.

New rather than reused, for a reason specific to each candidate. :class:~groundkit.contracts.RetrievalResult requires score with ge=0.0; a fetch has no score, and supplying 0.0 would invent a number SPEC.md §2 forbids and drive is_high_confidence off a fabricated value. :class:~groundkit.contracts.Chunk carries no source — only Document does (ADR-0006) — so it cannot express a citation. The real :class:~groundkit.contracts.Citation is therefore nested rather than restated field by field.

content is the text read from the source, not the indexed copy, and is None unless the verdict is verified. That is the point of the operation: search returns indexed text at speed, and this is the step a client performs on a hit it intends to quote. A non-verified response is still HTTP 200 — a drifted source is an ordinary state of a live local corpus, and the verdict is the useful part of the answer — but it carries no text a caller could mistakenly attribute to the source.

FetchChunkRequest

Bases: BaseModel

Fetch one chunk and re-verify its citation against the source.

IndexStatusRequest

Bases: BaseModel

Report one collection's size and embedding identity.

IndexStatusResponse

Bases: BaseModel

Collection size and embedding identity — never content or layout.

Nests the real :class:~groundkit.contracts.CollectionManifest rather than projecting it: contracts.py already documents that model as identity minus operational settings, which is exactly the leak-safe subset a status surface needs, so reusing it means the safe subset is a contract instead of a hand-picked list that could drift.

Deliberately absent: document sources, chunk content, queries, the index directory, the containment root, base_url, and api_key_env. SPEC.md §7 records that SQLite here is content-bearing data; a status endpoint that enumerated sources would be disclosing corpus layout to an unauthenticated reader.

generation is None when the collection predates ADR-0013's marker, which is also when cache_enabled is False — surfaced so a degraded collection is visible directly rather than inferred from latency.

The four retriever_*/rebuild_* fields are that same argument applied to the cache's behaviour rather than to its availability (ADR-0026). cache_enabled: true says the cache is switched on; it says nothing about whether it is hitting, and under a concurrent grk ingest it can be on and hitting almost never. These counters make that visible instead of inferrable from latency. They describe this process's work against the collection, not the collection, so they reset when the registry evicts and reopens a runtime — see :class:~groundkit.runtime.RebuildStats.

Adding them discloses nothing new. A rebuild happens exactly when the generation moved, and generation is already reported on this same response — a strictly finer-grained signal of the same write activity. No field here names a source, a query, a path, or a byte of content.

ListCollectionsRequest

Bases: BaseModel

No parameters.

A model rather than None so every entry in the tool registry has a request_model to generate an MCP input schema from and to parse a REST body with. A uniform registry is what lets the parity tests compare sets instead of special-casing one operation, and extra="forbid" still means a caller who sends a field learns it was not understood.

SearchRequest

Bases: BaseModel

A search over one collection.

query and top_k carry real bounds rather than being validated in the handler, and that placement is what makes ADR-0014's error mapping correct. Retriever.search raises RetrievalError both for a caller error (empty query, out-of-range top_k) and for a server-side index inconsistency, and the type does not distinguish them. Bounding here means the caller-error cases are rejected by schema validation before reaching the retriever, so the only RetrievalError that can surface from a handler is the inconsistency one — which is why it maps to a conflict rather than a bad request. Relax these bounds and that mapping silently becomes wrong.

Error mapping

groundkit.service.errors

One exception-to-transport mapping, used identically by REST and MCP (ADR-0014 decision 9).

No new exception type is defined here, and none may be. errors.py's hierarchy is sufficient; this module only decides how each existing type is rendered at an egress boundary. That constraint is what keeps the two transports honest: they cannot disagree about what a given failure means, because neither one classifies anything — both call :func:map_exception and render what it returns.

Three properties are load-bearing rather than stylistic.

Matching is subclass-first, and the order is data rather than control flow. :data:_MAPPINGS is an ordered tuple walked front to back, with every subclass ahead of its base. A mis-ordered chain is a real bug class — put RetrievalError before RerankerNotConfiguredError and a missing optional extra starts reporting as an index inconsistency — so test_service_errors.py asserts the ordering property directly against the tuple rather than trusting that the entries were written in a sensible sequence.

Only an allow-list of types has its own message returned to a caller. Every other type renders a fixed detail. The distinction is not severity, it is provenance: a :class:~groundkit.errors.ConfigurationError names a value the caller supplied, while an :class:~groundkit.errors.EmbeddingError may carry a sanitized provider URL, an endpoint, or text a provider returned. Credential safety here is by construction rather than by scrubbing at the edge — the class that could carry a secret never has its message read at all.

The mapper never reads __cause__, __context__, repr(exc), or a traceback. That is the ADR-0001 hazard 6 chain-severing discipline applied at the egress boundary: a scrubbed message with an unscrubbed __cause__ chained behind it is the exact shape that leaked credentials in the ported code this repo replaced. The full, unscrubbed exception is logged server-side against the response's request id instead, so nothing is lost to an operator.

The not-found question, settled

CollectionRegistry raises :class:~groundkit.errors.ConfigurationError for a collection that does not exist, while ADR-0014 decision 3 calls that a not-found and decision 9 maps ConfigurationError to bad request. Read naively the two conflict. They do not, because decision 3 does not describe an exception path at all: the not-found is produced by :func:check_collection as a precondition at the boundary, before any handler runs, so the registry's own refusal is defense-in-depth that a well-formed request never reaches. No message is matched and no type is distinguished — the two answers come from two different places in the call, which is why this is robust rather than a string comparison waiting to rot.

One residual asymmetry is recorded rather than left to omission: a missing chunk_id surfaces as 400, not 404. handle_fetch_chunk raises ConfigurationError for it, and separating that from an invalid collection name would require either a new exception type (forbidden by ADR-0014 decision 9) or message matching (forbidden for being fragile). 400 is also the reading decision 9's own rationale gives — "every reachable one in Phase 4 is caused by a request field", and chunk_id is a request field. ADR-0014 carries a matching amendment; this docstring and that amendment are the same decision written twice on purpose, because a reader hitting a 400 on a GET of a missing chunk will look here first.

ErrorRendering dataclass

ErrorRendering(
    status_code: int, rpc_code: int, detail: str, kind: str
)

How one failure is rendered on both transports.

Attributes:

Name Type Description
status_code int

HTTP status for the REST surface.

rpc_code int

JSON-RPC error code carried in the MCP error payload.

detail str

Text safe to return to a caller — either the exception's own message (allow-listed types only) or :data:GENERIC_DETAIL.

kind str

Stable machine-readable label. A client branching on this is not parsing prose, which is the failure mode detail invites.

check_collection

check_collection(
    index_dir: Path, collection: str
) -> ErrorRendering | None

Validate a collection name, then confirm the collection exists.

None means the request may proceed. This is ADR-0014 decision 3's precondition, and the order of the two checks is the security property: name validation runs first, so a traversal attempt is reported as a validation failure rather than as a not-found that would have confirmed whether the traversed-to path exists. A probe learns nothing either way.

It also keeps SQLiteMetadataStore.open off non-existent collections entirely. That method creates the file when absent — correct for grk ingest, and a disk-fill primitive at an unauthenticated read boundary, where index_status?collection=<anything> would otherwise write to disk on every request.

Parameters:

Name Type Description Default
index_dir Path

Directory holding the collections.

required
collection str

Caller-supplied collection name.

required

Returns:

Type Description
ErrorRendering | None

None when the request may proceed, otherwise the rendering to

ErrorRendering | None

return without invoking any handler.

Source code in src/groundkit/service/errors.py
def check_collection(index_dir: Path, collection: str) -> ErrorRendering | None:
    """Validate a collection name, then confirm the collection exists.

    ``None`` means the request may proceed. This is ADR-0014 decision 3's
    precondition, and the **order of the two checks is the security property**:
    name validation runs first, so a traversal attempt is reported as a
    validation failure rather than as a not-found that would have confirmed
    whether the traversed-to path exists. A probe learns nothing either way.

    It also keeps ``SQLiteMetadataStore.open`` off non-existent collections
    entirely. That method *creates* the file when absent — correct for
    ``grk ingest``, and a disk-fill primitive at an unauthenticated read
    boundary, where ``index_status?collection=<anything>`` would otherwise write
    to disk on every request.

    Args:
        index_dir: Directory holding the collections.
        collection: Caller-supplied collection name.

    Returns:
        ``None`` when the request may proceed, otherwise the rendering to
        return without invoking any handler.
    """
    try:
        validate_collection_name(collection)
    except ConfigurationError as exc:
        return map_exception(exc)

    if not (index_dir / f"{collection}.sqlite3").is_file():
        return ErrorRendering(
            status_code=404,
            rpc_code=_RPC_NOT_FOUND,
            # Echoes the name back because it passed validation, so it is a
            # known-safe string, and a caller with a typo needs to see it. The
            # index directory is NOT named: that is server layout.
            detail=(
                f"collection {collection!r} does not exist. Collections are created by "
                "`grk ingest`, never by reading one."
            ),
            kind="collection_not_found",
        )
    return None

map_exception

map_exception(exc: GroundkitError) -> ErrorRendering

Render exc for a caller, subclass-first.

Reads exc's own message only for allow-listed types, and reads nothing else about it — not its cause, its context, its repr, or its traceback.

Parameters:

Name Type Description Default
exc GroundkitError

The exception raised by a handler.

required

Returns:

Type Description
ErrorRendering

The rendering both transports use.

Source code in src/groundkit/service/errors.py
def map_exception(exc: GroundkitError) -> ErrorRendering:
    """Render ``exc`` for a caller, subclass-first.

    Reads ``exc``'s own message only for allow-listed types, and reads nothing
    else about it — not its cause, its context, its ``repr``, or its traceback.

    Args:
        exc: The exception raised by a handler.

    Returns:
        The rendering both transports use.
    """
    for exc_type, status, rpc_code, kind, expose in _MAPPINGS:
        if isinstance(exc, exc_type):
            return ErrorRendering(
                status_code=status,
                rpc_code=rpc_code,
                detail=str(exc) if expose else GENERIC_DETAIL,
                kind=kind,
            )
    # Unreachable while GroundkitError anchors the tuple, and deliberately not
    # written as an assert: a stripped-optimized run must still fail closed
    # rather than fall off the end returning None.
    return ErrorRendering(500, _RPC_INTERNAL, GENERIC_DETAIL, "internal_error")

unexpected_error_rendering

unexpected_error_rendering() -> ErrorRendering

Render a non-GroundkitError escape.

A bare Exception reaching a transport is a bug, and its message is whatever an arbitrary library chose to write — which is precisely why it is never returned. Both transports call this so an unexpected failure is rendered by the same rule as an expected one.

Source code in src/groundkit/service/errors.py
def unexpected_error_rendering() -> ErrorRendering:
    """Render a non-``GroundkitError`` escape.

    A bare ``Exception`` reaching a transport is a bug, and its message is
    whatever an arbitrary library chose to write — which is precisely why it is
    never returned. Both transports call this so an unexpected failure is
    rendered by the same rule as an expected one.
    """
    return ErrorRendering(500, _RPC_INTERNAL, GENERIC_DETAIL, "internal_error")

REST surface

groundkit.service.api

The REST half of Phase 4's read-only surface, generated from the registry (ADR-0014).

Every route this app carries is produced by iterating :data:~groundkit.service.tools.TOOLS. Nothing is registered with a bare decorator, and that is exactly what ADR-0014 decision 2 check 2 tests: a hand-added route is a (path, method) pair the registry does not contain, so comparing the two sets decides the question. Generation is therefore not a tidiness preference — it is what turns "this surface exposes exactly the four declared read-only operations" into a property a test can settle, rather than a claim a reviewer has to re-derive by reading the file each time it changes.

This module deliberately does not import :mod:groundkit.service.mcp_server. An MCP transport arrives as an :class:McpMount the caller constructs, so the REST surface stays constructible and testable without the MCP SDK's session lifecycle. Inverting the dependency is what keeps that true: importing the server module for the convenience of a default would couple every REST test to a session manager it never exercises, and would make a broken MCP import break plain grk serve.

Method shapes are decided, not incidental

search is a POST with a body even though it mutates nothing. Access logs record the request line at INFO, so a query travelling in a query string writes every query into an INFO log — directly against SPEC.md §3. That is also why method alone cannot certify read-only-ness and the registry has to (ADR-0014 alternatives). The three reads that carry no free text are GETs whose parameters live in the path; no route accepts a query parameter, and none may, because a query parameter is the shape configuration would arrive in.

Errors and logging split by audience, on purpose

A handler's exception is rendered for the caller by :mod:groundkit.service.errors — which never reads __cause__, __context__, repr(exc), or a traceback — while the full, unscrubbed exception goes to the server log at ERROR against the response's request id. The two halves are joined only by that id, which every response carries in the X-Request-ID header. The header is the carrier rather than a body field because search returns :class:~groundkit.contracts.SearchResponse unchanged (ADR-0014 decision 8), so there is nowhere in a success body to put one without inventing the parallel DTO that decision exists to refuse.

The INFO access log carries the request id, method, route, tool, status, latency and result count, and never the query text; query text is DEBUG only (SPEC.md §3, ADR-0014 decision 9).

McpMount dataclass

McpMount(
    path: str,
    app: ASGIApp,
    lifespan: Callable[
        [], AbstractAsyncContextManager[None]
    ],
)

An MCP transport to mount, supplied by the caller.

api.py deliberately does NOT import service/mcp_server.py: the REST surface must be constructible and testable without the MCP SDK's session lifecycle, and inverting the dependency here is what keeps that true.

Attributes:

Name Type Description
path str

Sub-path the transport is mounted at.

app ASGIApp

The transport's ASGI application.

lifespan Callable[[], AbstractAsyncContextManager[None]]

Zero-argument factory returning the transport's async context manager. A factory rather than an already-entered context manager because :func:create_app may be called before any event loop exists, and an AsyncContextManager created eagerly at that moment would bind to the wrong loop — or to none.

create_app

create_app(
    ctx: ServiceContext,
    *,
    mcp_mount: McpMount | None = None,
    host_allow_list: HostAllowList = LOOPBACK_HOST_ALLOW_LIST,
) -> FastAPI

Build the read-only REST app, one route per registered tool.

With mcp_mount omitted the app is pure REST and pulls in nothing from the MCP SDK. With it supplied, its ASGI app is mounted at its path and its lifespan is entered inside the app's own lifespan, which is the only place a streamable-HTTP session manager can be started and stopped in step with the server process.

Host is validated, because the loopback bind alone is not a boundary

TrustedHostMiddleware is installed unconditionally — it is middleware, so it also covers mcp_mount's sub-path, and the MCP transport's own check (create_session_manager) is a second, independent refusal rather than the only one. What varies is the list it is given, and that is a serve-time decision derived once by :func:groundkit.service.binding.derive_host_allow_list (ADR-0024): a page on any site the victim visits can re-point its own hostname at 127.0.0.1, and the browser will then treat this service as same-origin. Host is the only part of such a request that still names the attacker.

The default is :data:~groundkit.service.binding.LOOPBACK_HOST_ALLOW_LIST (ADR-0025, amending ADR-0024's original choice of :data:~groundkit.service.binding.UNRESTRICTED_HOST_ALLOW_LIST): create_app builds an app object, while serving it is what makes a Host decision meaningful, and grk serve always passes the derived list regardless of this default — the two coincide only for the common case, a loopback bind, which is exactly why the safer constant was available to default to. A caller that constructs the app for an in-process client, a test, or behind its own front door and never thinks about host_allow_list at all now gets the same fail-closed posture grk serve gives its own default bind, rather than every Host silently accepted; one that needs the wider behavior (embedding behind a reverse proxy under a name this process cannot predict) still opts in explicitly with :data:~groundkit.service.binding.UNRESTRICTED_HOST_ALLOW_LIST. The security property is asserted against both the CLI-assembled app and this bare default in tests/test_service_host_validation.py.

The context's registry is closed on shutdown. The app does not own the :class:~groundkit.service.tools.ServiceContext — it is assembled at serve time and handed in — but it is the only component that knows when serving has stopped, and CollectionRegistry.aclose is idempotent, so a caller that also closes it is correct rather than in conflict. The consequence worth naming: an app whose lifespan has completed cannot serve again, because its registry is closed.

Parameters:

Name Type Description Default
ctx ServiceContext

Serve-time context. Everything a handler may reach lives here; no request model carries any of it (ADR-0014 decision 6).

required
mcp_mount McpMount | None

Optional MCP transport to mount alongside the REST routes.

None
host_allow_list HostAllowList

The Host decision both transports enforce. Defaults to unrestricted; grk serve derives a real one.

LOOPBACK_HOST_ALLOW_LIST

Returns:

Type Description
FastAPI

A FastAPI application exposing exactly the operations in

FastAPI

data:~groundkit.service.tools.TOOLS.

Source code in src/groundkit/service/api.py
def create_app(
    ctx: ServiceContext,
    *,
    mcp_mount: McpMount | None = None,
    host_allow_list: HostAllowList = LOOPBACK_HOST_ALLOW_LIST,
) -> FastAPI:
    """Build the read-only REST app, one route per registered tool.

    With ``mcp_mount`` omitted the app is pure REST and pulls in nothing from
    the MCP SDK. With it supplied, its ASGI app is mounted at its path and its
    lifespan is entered inside the app's own lifespan, which is the only place
    a streamable-HTTP session manager can be started and stopped in step with
    the server process.

    ## ``Host`` is validated, because the loopback bind alone is not a boundary

    ``TrustedHostMiddleware`` is installed **unconditionally** — it is
    middleware, so it also covers ``mcp_mount``'s sub-path, and the MCP
    transport's own check (``create_session_manager``) is a second, independent
    refusal rather than the only one. What varies is the list it is given, and
    that is a serve-time decision derived once by
    :func:`groundkit.service.binding.derive_host_allow_list` (ADR-0024): a page
    on any site the victim visits can re-point its own hostname at
    ``127.0.0.1``, and the browser will then treat this service as same-origin.
    ``Host`` is the only part of such a request that still names the attacker.

    The default is :data:`~groundkit.service.binding.LOOPBACK_HOST_ALLOW_LIST`
    (ADR-0025, amending ADR-0024's original choice of
    :data:`~groundkit.service.binding.UNRESTRICTED_HOST_ALLOW_LIST`): ``create_app``
    builds an app object, while *serving* it is what makes a ``Host`` decision
    meaningful, and ``grk serve`` always passes the derived list regardless of
    this default — the two coincide only for the common case, a loopback bind,
    which is exactly why the safer constant was available to default to. A
    caller that constructs the app for an in-process client, a test, or behind
    its own front door and never thinks about ``host_allow_list`` at all now
    gets the same fail-closed posture ``grk serve`` gives its own default bind,
    rather than every ``Host`` silently accepted; one that needs the wider
    behavior (embedding behind a reverse proxy under a name this process
    cannot predict) still opts in explicitly with
    :data:`~groundkit.service.binding.UNRESTRICTED_HOST_ALLOW_LIST`. The
    security property is asserted against both the CLI-assembled app and this
    bare default in ``tests/test_service_host_validation.py``.

    The context's registry is closed on shutdown. The app does not *own* the
    :class:`~groundkit.service.tools.ServiceContext` — it is assembled at serve
    time and handed in — but it is the only component that knows when serving
    has stopped, and ``CollectionRegistry.aclose`` is idempotent, so a caller
    that also closes it is correct rather than in conflict. The consequence
    worth naming: an app whose lifespan has completed cannot serve again,
    because its registry is closed.

    Args:
        ctx: Serve-time context. Everything a handler may reach lives here;
            no request model carries any of it (ADR-0014 decision 6).
        mcp_mount: Optional MCP transport to mount alongside the REST routes.
        host_allow_list: The ``Host`` decision both transports enforce.
            Defaults to unrestricted; ``grk serve`` derives a real one.

    Returns:
        A FastAPI application exposing exactly the operations in
        :data:`~groundkit.service.tools.TOOLS`.
    """

    @asynccontextmanager
    async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
        try:
            if mcp_mount is None:
                yield
            else:
                async with mcp_mount.lifespan():
                    yield
        finally:
            await ctx.registry.aclose()

    app = FastAPI(
        title="groundkit",
        version=__version__,
        summary=(
            "Read-only retrieval surface: search, fetch_chunk, list_collections, index_status."
        ),
        lifespan=lifespan,
    )
    app.add_exception_handler(RequestValidationError, _handle_validation_error)
    # `www_redirect=False`: the default answers an unmatched `Host` whose
    # `www.`-prefixed form IS on the list with a 307 to that name. No entry this
    # repo produces starts with `www.`, so the branch is unreachable — but it is
    # a redirect built from a caller-supplied header, and turning it off costs a
    # keyword rather than an argument about whether it can be reached.
    app.add_middleware(
        TrustedHostMiddleware,
        allowed_hosts=list(host_allow_list.trusted_hosts),
        www_redirect=False,
    )

    for spec in TOOLS:
        _register(app, spec, ctx)

    if mcp_mount is not None:
        # A Mount, not an APIRoute, so it does not participate in the
        # registry<->route parity comparison. That is correct rather than a
        # loophole: the MCP surface has its own parity test against the same
        # registry (ADR-0014 decision 2 check 3), and asserting the mount here
        # would be asserting the same thing twice from the weaker side.
        app.mount(mcp_mount.path, mcp_mount.app)

    return app

MCP server

groundkit.service.mcp_server

Named MCP server over stdio and streamable HTTP (Phase 4, ADR-0014).

Every tool is generated by iterating :data:~groundkit.service.tools.TOOLS. Nothing here hand-registers an operation, and that is ADR-0014 decision 2 check 3: an MCP-only tool is caught exactly as a REST-only route is, because :func:list_tool_definitions drives the server's registered handler and tests/test_service_mcp.py compares what comes back against TOOL_NAMES. Each tool's inputSchema is request_model.model_json_schema() — a hand-written second schema and a Pydantic model are two descriptions of one contract, which is precisely the seam drift ADR-0001 hazard 4 exists to catch (ADR-0014 decision 5).

The dispatch body catches everything, and that is a disclosure control

The SDK's generated tools/call handler ends in::

except Exception as e:
    return self._make_error_result(str(e))

and _make_error_result puts that raw string into the CallToolResult a client reads. An escaping exception therefore returns its own message to the caller, bypassing ADR-0014 decision 9's allow-list entirely — a :class:~groundkit.errors.StorageError carries an absolute database path and an :class:~groundkit.errors.EmbeddingError may carry a provider endpoint or response text. So the handler registered below catches :class:~groundkit.errors.GroundkitError and bare Exception itself and returns an explicit :class:mcp.types.CallToolResult built from :mod:groundkit.service.errors. The SDK's stringify branch is unreachable by construction rather than unlikely in practice, and test_service_mcp.py proves it with a sentinel planted in both a message and a __cause__.

CancelledError is deliberately not caught: it is a BaseException in 3.11+, so except Exception lets a cancelled request unwind rather than answering it with a fabricated error result.

Serialization runs inside that same guard, for the same reason. Returning a plain dict and letting the SDK render it would move json.dumps back inside the SDK's try, restoring the stringify path for anything unserializable.

Input validation is ours, not the SDK's

validate_input=False. With the SDK's own jsonschema check enabled, a bad argument returns f"Input validation error: {e.message}" — a bare string with no kind and no code, produced by a code path neither transport controls. That would make MCP and REST disagree about how a malformed request is rendered, which ADR-0014 decision 5 lists under must not differ. Parsing through spec.request_model instead means one validator (the model), one renderer (:mod:groundkit.service.errors), and the same answer on both surfaces.

stdout carries JSON-RPC frames and nothing else

ADR-0014 decision 5's stdio rule is load-bearing: a single log line on stdout corrupts the protocol stream. :func:run_stdio therefore re-points every stdout-bound stream handler at stderr before the server runs, and a test captures stdout across a real tool call and asserts every line parses as JSON. A stray print() in a handler is still fatal and cannot be defended against from here; nothing in :mod:groundkit.service prints.

build_mcp_server

build_mcp_server(ctx: ServiceContext) -> Server[Any, Any]

Build the MCP server for ctx, with one tool per registry entry.

ctx is captured once, at build time. That is ADR-0014 decision 6 in mechanical form: provider settings, the index directory and the containment root reach a handler only through this object, so no request model needs to carry them and no request can reach them.

Parameters:

Name Type Description Default
ctx ServiceContext

Serve-time context every handler is invoked with.

required

Returns:

Type Description
Server[Any, Any]

A server with tools/list and tools/call registered.

Source code in src/groundkit/service/mcp_server.py
def build_mcp_server(ctx: ServiceContext) -> Server[Any, Any]:
    """Build the MCP server for ``ctx``, with one tool per registry entry.

    ``ctx`` is captured once, at build time. That is ADR-0014 decision 6 in
    mechanical form: provider settings, the index directory and the containment
    root reach a handler only through this object, so no request model needs to
    carry them and no request can reach them.

    Args:
        ctx: Serve-time context every handler is invoked with.

    Returns:
        A server with ``tools/list`` and ``tools/call`` registered.
    """
    server: Server[Any, Any] = Server(
        name=MCP_SERVER_NAME,
        version=__version__,
        instructions=_INSTRUCTIONS,
    )

    async def _list_tools() -> list[types.Tool]:
        return [_tool_definition(spec) for spec in TOOLS]

    async def _call_tool(name: str, arguments: dict[str, Any]) -> types.CallToolResult:
        """Answer one ``tools/call``, and never let an exception escape.

        Both ``except`` clauses are load-bearing, not defensive style: an escape
        reaches the SDK's ``return self._make_error_result(str(e))``, which puts
        the raw message in front of the caller. See this module's docstring.
        """
        request_id = uuid4().hex
        try:
            return await _dispatch(ctx, name, arguments, request_id)
        except GroundkitError as exc:
            # Mapped BEFORE logging, not inline in the return below, so the log
            # record carries the same typed `kind` the caller receives. Without
            # it a collector sees only status="failed" and cannot tell a
            # storage fault from a retrieval one from an index inconsistency —
            # the precondition-refusal path above already logs `failure_kind`,
            # and this being the odd one out was the inconsistency.
            rendering = map_exception(exc)
            logger.error(
                "mcp tool=%s request_id=%s failed kind=%s",
                name,
                request_id,
                rendering.kind,
                exc_info=exc,
                extra={
                    "tool": name,
                    "request_id": request_id,
                    "status": "failed",
                    "failure_kind": rendering.kind,
                },
            )
            return _error_result(rendering, request_id)
        except Exception as exc:
            logger.error(
                "mcp tool=%s request_id=%s failed unexpectedly",
                name,
                request_id,
                exc_info=exc,
                extra={"tool": name, "request_id": request_id, "status": "failed_unexpectedly"},
            )
            return _error_result(unexpected_error_rendering(), request_id)

    # Registered by *calling* the SDK's decorator factories rather than with
    # decorator syntax. They are unannotated, and `mypy --strict` turns on
    # `disallow_untyped_decorators`, so the `@` form reports `untyped-decorator`
    # on *both* handlers — erasing their types — on top of the call below. The
    # `@` form costs three suppressions here and this form costs one, for the
    # same call. `unused-ignore` is paired on so a future SDK release that
    # annotates `list_tools` does not turn a stale suppression into a failure.
    server.list_tools()(_list_tools)  # type: ignore[no-untyped-call,unused-ignore]
    server.call_tool(validate_input=False)(_call_tool)
    return server

create_session_manager

create_session_manager(
    ctx: ServiceContext,
    *,
    host_allow_list: HostAllowList = LOOPBACK_HOST_ALLOW_LIST,
) -> StreamableHTTPSessionManager

Build the streamable-HTTP session manager to mount at :data:MCP_HTTP_PATH.

The caller owns the lifecycle and must enter manager.run() for the app's lifetime before routing anything to manager.handle_request; the instance is single-use and cannot be restarted.

Stateful with an idle timeout, rather than stateless=True: sessions are what the standard transport negotiates, and the reason to avoid them here would be unbounded growth on an unauthenticated port, which :data:_SESSION_IDLE_TIMEOUT_SECONDS bounds directly. event_store is None because a read-only surface has nothing to replay — resumability exists for streams a client must not miss events from, and every response here is reproducible by asking again.

DNS-rebinding protection is on, driven by the serve-time bind decision (ADR-0024). The SDK defaults security_settings to off for backwards compatibility, and its own convenience server (FastMCP) overrides that default for a loopback host precisely because the bind is not a boundary against a browser: a page can re-point its own hostname at 127.0.0.1, and every request the browser then makes is same-origin — no preflight, the response readable. This module uses the lower-level Server / StreamableHTTPSessionManager API, which bypasses that override, so the settings are built here from :class:~groundkit.service.binding.HostAllowList instead. Both the Host and the Origin list come from that one object, so this transport and the REST surface cannot drift into disagreeing about who may connect.

This check is not the only one on the HTTP path: mounted under :func:groundkit.service.api.create_app, Starlette's TrustedHostMiddleware has already refused a forged Host before the request reaches here. It is duplicated deliberately, because the manager is a public constructor a caller may mount anywhere, and a transport whose protection depends on the app someone wrapped it in has no protection of its own. The two refusals differ in status code (Starlette answers 400, the SDK 421/403) and that is left alone: each matcher renders its own refusal, and wrapping them into one shape would mean re-implementing both.

The default is :data:~groundkit.service.binding.LOOPBACK_HOST_ALLOW_LIST (ADR-0025), for the reason :func:groundkit.service.api.create_app gives about its own: the bind decision is made at serve time, and grk serve always passes the derived list regardless of this default, so the two coincide only for the common case, a loopback bind. A caller that never passes host_allow_list gets the same fail-closed posture as that default bind rather than every Host accepted; one that needs the wider behavior opts in explicitly with :data:~groundkit.service.binding.UNRESTRICTED_HOST_ALLOW_LIST.

Parameters:

Name Type Description Default
ctx ServiceContext

Serve-time context every handler is invoked with.

required
host_allow_list HostAllowList

The Host/Origin decision this transport enforces, derived by :func:groundkit.service.binding.derive_host_allow_list.

LOOPBACK_HOST_ALLOW_LIST

Returns:

Type Description
StreamableHTTPSessionManager

A manager wrapping a server built from the same registry as stdio's.

Source code in src/groundkit/service/mcp_server.py
def create_session_manager(
    ctx: ServiceContext,
    *,
    host_allow_list: HostAllowList = LOOPBACK_HOST_ALLOW_LIST,
) -> StreamableHTTPSessionManager:
    """Build the streamable-HTTP session manager to mount at :data:`MCP_HTTP_PATH`.

    The caller owns the lifecycle and must enter ``manager.run()`` for the app's
    lifetime before routing anything to ``manager.handle_request``; the instance
    is single-use and cannot be restarted.

    Stateful with an idle timeout, rather than ``stateless=True``: sessions are
    what the standard transport negotiates, and the reason to avoid them here
    would be unbounded growth on an unauthenticated port, which
    :data:`_SESSION_IDLE_TIMEOUT_SECONDS` bounds directly. ``event_store`` is
    ``None`` because a read-only surface has nothing to replay — resumability
    exists for streams a client must not miss events from, and every response
    here is reproducible by asking again.

    **DNS-rebinding protection is on, driven by the serve-time bind decision**
    (ADR-0024). The SDK defaults ``security_settings`` to off for backwards
    compatibility, and its own convenience server (``FastMCP``) overrides that
    default for a loopback host precisely because the bind is not a boundary
    against a browser: a page can re-point its own hostname at ``127.0.0.1``,
    and every request the browser then makes is same-origin — no preflight, the
    response readable. This module uses the lower-level ``Server`` /
    ``StreamableHTTPSessionManager`` API, which bypasses that override, so the
    settings are built here from
    :class:`~groundkit.service.binding.HostAllowList` instead. Both the
    ``Host`` and the ``Origin`` list come from that one object, so this
    transport and the REST surface cannot drift into disagreeing about who may
    connect.

    This check is **not** the only one on the HTTP path: mounted under
    :func:`groundkit.service.api.create_app`, Starlette's
    ``TrustedHostMiddleware`` has already refused a forged ``Host`` before the
    request reaches here. It is duplicated deliberately, because the manager is
    a public constructor a caller may mount anywhere, and a transport whose
    protection depends on the app someone wrapped it in has no protection of
    its own. The two refusals differ in status code (Starlette answers 400, the
    SDK 421/403) and that is left alone: each matcher renders its own refusal,
    and wrapping them into one shape would mean re-implementing both.

    The default is :data:`~groundkit.service.binding.LOOPBACK_HOST_ALLOW_LIST`
    (ADR-0025), for the reason :func:`groundkit.service.api.create_app` gives
    about its own: the bind decision is made at serve time, and ``grk serve``
    always passes the derived list regardless of this default, so the two
    coincide only for the common case, a loopback bind. A caller that never
    passes ``host_allow_list`` gets the same fail-closed posture as that
    default bind rather than every ``Host`` accepted; one that needs the wider
    behavior opts in explicitly with
    :data:`~groundkit.service.binding.UNRESTRICTED_HOST_ALLOW_LIST`.

    Args:
        ctx: Serve-time context every handler is invoked with.
        host_allow_list: The ``Host``/``Origin`` decision this transport
            enforces, derived by
            :func:`groundkit.service.binding.derive_host_allow_list`.

    Returns:
        A manager wrapping a server built from the same registry as stdio's.
    """
    return StreamableHTTPSessionManager(
        app=build_mcp_server(ctx),
        event_store=None,
        json_response=False,
        stateless=False,
        session_idle_timeout=_SESSION_IDLE_TIMEOUT_SECONDS,
        security_settings=TransportSecuritySettings(
            enable_dns_rebinding_protection=host_allow_list.enforced,
            allowed_hosts=list(host_allow_list.mcp_allowed_hosts),
            allowed_origins=list(host_allow_list.mcp_allowed_origins),
        ),
    )

list_tool_definitions async

list_tool_definitions(
    ctx: ServiceContext,
) -> list[types.Tool]

Return the tools a freshly built server actually registers.

Drives the registered tools/list handler rather than re-deriving the list from :data:TOOLS. That distinction is the whole value of the parity test: re-deriving would compare the registry against itself and pass even if registration were broken or a tool were added directly to the server.

Raises:

Type Description
ConfigurationError

The installed SDK did not answer tools/list with a ListToolsResult. Unreachable under the mcp>=1.29,<2 pin, which exists for exactly this dispatch contract.

Source code in src/groundkit/service/mcp_server.py
async def list_tool_definitions(ctx: ServiceContext) -> list[types.Tool]:
    """Return the tools a freshly built server actually registers.

    Drives the registered ``tools/list`` handler rather than re-deriving the
    list from :data:`TOOLS`. That distinction is the whole value of the parity
    test: re-deriving would compare the registry against itself and pass even if
    registration were broken or a tool were added directly to the server.

    Raises:
        ConfigurationError: The installed SDK did not answer ``tools/list``
            with a ``ListToolsResult``. Unreachable under the ``mcp>=1.29,<2``
            pin, which exists for exactly this dispatch contract.
    """
    server = build_mcp_server(ctx)
    result = await server.request_handlers[types.ListToolsRequest](None)
    payload = result.root
    if isinstance(payload, types.ListToolsResult):
        return list(payload.tools)
    raise ConfigurationError(
        "the installed MCP SDK answered tools/list with "
        f"{type(payload).__name__}, not ListToolsResult; its dispatch contract has changed"
    )

run_stdio async

run_stdio(ctx: ServiceContext) -> None

Serve MCP over stdio until the client closes stdin.

Logging is re-pointed before the server runs, because the first record written after the transport opens is already too late.

Parameters:

Name Type Description Default
ctx ServiceContext

Serve-time context every handler is invoked with.

required
Source code in src/groundkit/service/mcp_server.py
async def run_stdio(ctx: ServiceContext) -> None:
    """Serve MCP over stdio until the client closes stdin.

    Logging is re-pointed **before** the server runs, because the first record
    written after the transport opens is already too late.

    Args:
        ctx: Serve-time context every handler is invoked with.
    """
    _force_logging_to_stderr()
    server = build_mcp_server(ctx)
    async with stdio_server() as (read_stream, write_stream):
        await server.run(read_stream, write_stream, server.create_initialization_options())

Host binding

groundkit.service.binding

Inbound bind-host classification for grk serve (ADR-0014 decision 7).

This is the service's only access control. ADR-0014 decision 1 makes every Phase 4 operation read-only, which satisfies SPEC.md §7's shared-secret clause vacuously — the set of mutating operations is empty, so the set requiring the header is empty — and therefore no authentication of any kind ships in Phase 4. SPEC.md §7 also records that the SQLite index is content-bearing: a search response carries document text and absolute source paths, and index_status/list_collections carry collection topology. With no credential to present and nothing to present it to, the address the process binds is the entire boundary between that corpus and everyone else. It is load-bearing, not a default, and this module exists so that it is enforced in one named place rather than spelled as an argparse default that a later edit could quietly widen.

Polarity note — the two address classifiers in this repo disagree on purpose, and must not be "unified". :mod:groundkit.utils.url_safety rejects loopback (and every other non-global address) because it guards an outbound provider endpoint, where reaching loopback is SSRF. This module requires loopback because it guards an inbound bind, where reaching anything else publishes the corpus. Same :mod:ipaddress classification, opposite verdict, because the direction of the connection is what makes an address dangerous. Merging them would have to pick one verdict and would silently invert the other guard.

The bind alone was not enough, and ADR-0024 records why. A loopback socket is unreachable from off-box at the IP layer, but a browser can be walked onto it: a page on any site the victim visits re-points its own hostname at 127.0.0.1 with a short-TTL answer, and every subsequent request the browser makes to that name is same-origin — no CORS preflight, response fully readable. The packet still comes from loopback; the attacker is not on the box. The only thing that distinguishes such a request from a legitimate one is the Host header, which still carries the attacker's name, so this module also derives the Host allow-list both transports enforce (:func:derive_host_allow_list). It lives here for the same reason the bind classification does — one named place, where widening it is a visible edit rather than a default nobody reads.

Two questions about the same address, and only one of them resolves a name. Whether a bind may proceed is decided without a resolver (:func:_is_loopback_literal), because a wrong answer there hands the operator a routable socket they were told was loopback. Whether Host validation is enforced is decided with one (:func:derive_host_allow_list), because a wrong answer there costs at most a refused client. That is not an inconsistency: the two errors are not the same size, and the direction each one fails in is what picks the rule. :func:derive_host_allow_list argues it in full, because the version of this module that used one predicate for both questions turned Host validation off for --host localhost --allow-remote-access — a non-routable socket, which is the only kind rebinding can reach.

Classification is done with :mod:ipaddress, never a regex: leading zeros, alternate bases, embedded scope ids and IPv4-in-IPv6 spellings are exactly what a regex gets subtly wrong, and the stdlib already answers this question.

HostAllowList dataclass

HostAllowList(
    enforced: bool,
    mcp_allowed_hosts: tuple[str, ...],
    mcp_allowed_origins: tuple[str, ...],
    trusted_hosts: tuple[str, ...],
)

One Host decision, rendered into each matcher's own dialect.

Both transports enforce the same decision and neither re-derives it: :func:groundkit.service.mcp_server.create_session_manager reads the MCP fields, :func:groundkit.service.api.create_app reads :attr:trusted_hosts, and both read one object built in one place. An edit that widens one surface and forgets the other has to widen this object, where both readers see it.

Attributes:

Name Type Description
enforced bool

Whether Host is checked at all. False is the deliberately-published mode; see :func:derive_host_allow_list.

mcp_allowed_hosts tuple[str, ...]

Host patterns in the MCP SDK's dialect.

mcp_allowed_origins tuple[str, ...]

Origin patterns in the MCP SDK's dialect.

trusted_hosts tuple[str, ...]

allowed_hosts for Starlette's TrustedHostMiddleware, derived from mcp_allowed_hosts by :func:_trusted_host_pattern.

derive_host_allow_list

derive_host_allow_list(host: str) -> HostAllowList

Derive the Host allow-list that goes with a bind address.

Called after :func:ensure_bindable_host, which has already refused every non-loopback-literal address the operator did not acknowledge with --allow-remote-access. The bind address therefore already carries the flag's answer, which is why one parameter is sufficient.

This function resolves a hostname and :func:_is_loopback_literal refuses to, and that asymmetry is the design rather than a contradiction. Both are asking whether the socket is reachable only from this machine, and a resolver's answer to that is mutable either way — the ASGI server resolves the name again moments later, so the address seen here need not be the address bound. What differs is the cost of being wrong, and it is not symmetric:

  • For the bind, being wrong publishes a corpus. Resolution says loopback, the socket lands on a routable interface, and the operator was told the opposite by the guard that exists to tell them. So the bind classifies literals only.
  • For the allow-list, the two errors are different sizes. Resolution says loopback while the socket is really routable: the list is merely too narrow, legitimate clients get a 400 or a 421, and the operator sees it on their first request. Resolution says routable while the socket is really loopback: Host validation is switched off on the one kind of bind DNS rebinding can reach, which is the CRITICAL this module exists to close.

Resolution errs closed here and would err open there. That is the whole reason it is used here and refused there, and it also settles what an unresolvable name does: it stays restricted. A name nobody can resolve cannot be shown to be routable, and going unrestricted requires positive evidence of routability, not merely the absence of evidence against it. No second error message is invented for that case either — the bind that follows resolves the same name through the same resolver and fails there, with the ASGI server's own words.

Address literals are never resolved. A literal is both what this function classifies and what actually gets bound, so a resolver could only add a way to be wrong.

A routable bind is unrestricted, and that is the decision rather than an omission (ADR-0024 decision 3). Once the socket is genuinely routable — every routable literal, 0.0.0.0 and :: included, and every name that resolves onto one — Host validation protects nothing: rebinding exists to bridge a browser onto a service the attacker cannot route to, and an attacker who can route to it simply connects, to a service that ships no authentication either way. A restrictive list would also be wrong in every deployment that has one, since the service is reached through whatever name the operator's clients, reverse proxy or overlay network use and this process cannot know it. Refusing those requests would not add a boundary; it would make the flag unusable and push operators onto a fork with no check at all.

The tighter alternative — accept the bound address plus loopback — is recorded in ADR-0024 and rejected there: it breaks the reverse-proxy deployment, which is the one way an operator can put authentication in front of this service.

Parameters:

Name Type Description Default
host str

The address grk serve will bind, as typed, already accepted by :func:ensure_bindable_host.

required

Returns:

Type Description
HostAllowList

An enforced :func:_restricted_allow_list when the bind is loopback, or

HostAllowList

cannot be shown not to be; :data:UNRESTRICTED_HOST_ALLOW_LIST when it

HostAllowList

is routable.

Source code in src/groundkit/service/binding.py
def derive_host_allow_list(host: str) -> HostAllowList:
    """Derive the ``Host`` allow-list that goes with a bind address.

    Called *after* :func:`ensure_bindable_host`, which has already refused every
    non-loopback-literal address the operator did not acknowledge with
    ``--allow-remote-access``. The bind address therefore already carries the
    flag's answer, which is why one parameter is sufficient.

    **This function resolves a hostname and :func:`_is_loopback_literal`
    refuses to, and that asymmetry is the design rather than a contradiction.**
    Both are asking whether the socket is reachable only from this machine, and
    a resolver's answer to that is mutable either way — the ASGI server resolves
    the name again moments later, so the address seen here need not be the
    address bound. What differs is the cost of being wrong, and it is not
    symmetric:

    - For the **bind**, being wrong publishes a corpus. Resolution says
      loopback, the socket lands on a routable interface, and the operator was
      told the opposite by the guard that exists to tell them. So the bind
      classifies literals only.
    - For the **allow-list**, the two errors are different sizes. Resolution
      says loopback while the socket is really routable: the list is merely too
      *narrow*, legitimate clients get a 400 or a 421, and the operator sees it
      on their first request. Resolution says routable while the socket is
      really loopback: ``Host`` validation is switched off on the one kind of
      bind DNS rebinding can reach, which is the CRITICAL this module exists to
      close.

    Resolution errs closed here and would err open there. That is the whole
    reason it is used here and refused there, and it also settles what an
    **unresolvable** name does: it stays restricted. A name nobody can resolve
    cannot be shown to be routable, and going unrestricted requires positive
    evidence of routability, not merely the absence of evidence against it. No
    second error message is invented for that case either — the bind that
    follows resolves the same name through the same resolver and fails there,
    with the ASGI server's own words.

    **Address literals are never resolved.** A literal is both what this
    function classifies and what actually gets bound, so a resolver could only
    add a way to be wrong.

    **A routable bind is unrestricted, and that is the decision rather than an
    omission** (ADR-0024 decision 3). Once the socket is genuinely routable —
    every routable literal, ``0.0.0.0`` and ``::`` included, and every name that
    resolves onto one — ``Host`` validation protects nothing: rebinding exists
    to bridge a browser onto a service the attacker cannot route to, and an
    attacker who *can* route to it simply connects, to a service that ships no
    authentication either way. A restrictive list would also be wrong in every
    deployment that has one, since the service is reached through whatever name
    the operator's clients, reverse proxy or overlay network use and this
    process cannot know it. Refusing those requests would not add a boundary; it
    would make the flag unusable and push operators onto a fork with no check at
    all.

    The tighter alternative — accept the bound address plus loopback — is
    recorded in ADR-0024 and rejected there: it breaks the reverse-proxy
    deployment, which is the one way an operator can put authentication in front
    of this service.

    Args:
        host: The address ``grk serve`` will bind, as typed, already accepted by
            :func:`ensure_bindable_host`.

    Returns:
        An enforced :func:`_restricted_allow_list` when the bind is loopback, or
        cannot be shown not to be; :data:`UNRESTRICTED_HOST_ALLOW_LIST` when it
        is routable.
    """
    if _is_address_literal(host):
        if _is_loopback_literal(host):
            return _restricted_allow_list(host)
        logger.warning(
            "Host header validation is DISABLED because %s is a routable address. %s",
            host,
            _UNRESTRICTED_TAIL,
        )
        return UNRESTRICTED_HOST_ALLOW_LIST

    resolved = _resolved_addresses(host)
    if resolved is None:
        logger.warning(
            "Host header validation stays ENFORCED for the hostname %s: it does not resolve "
            "here, so it cannot be shown to reach anything outside loopback, and this "
            "derivation fails closed (ADR-0024). If the bind succeeds anyway and the address "
            "is in fact routable, legitimate clients will be refused with 400/421 -- bind an "
            "address literal instead.",
            host,
        )
        return _restricted_allow_list(host)

    answers = ", ".join(str(address) for address in resolved)
    if any(not _is_loopback_address(address) for address in resolved):
        logger.warning(
            "Host header validation is DISABLED because the hostname %s resolves to %s, which "
            "is not confined to loopback. %s",
            host,
            answers,
            _UNRESTRICTED_TAIL,
        )
        return UNRESTRICTED_HOST_ALLOW_LIST

    allow_list = _restricted_allow_list(host)
    logger.warning(
        "Host header validation stays ENFORCED: the hostname %s resolves only to %s, so "
        "--allow-remote-access widened the bind guard but published nothing. This socket is "
        "still reachable only from this machine, which is exactly the case DNS rebinding "
        "attacks, so the check is kept on and only this machine's own names are accepted: %s. "
        "Bind a routable address if you meant to publish the corpus.",
        host,
        answers,
        ", ".join(allow_list.mcp_allowed_hosts),
    )
    return allow_list

ensure_bindable_host

ensure_bindable_host(
    host: str, *, allow_remote_access: bool
) -> None

Refuse a non-loopback bind unless the operator acknowledged the exposure.

Called before anything else in grk serve — before the index directory is opened and before a registry exists — so a refused host costs nothing and touches nothing.

The refusal is literal-only and stays that way; see :func:_is_loopback_literal. What the acknowledgement means is not the same in both cases, though, so it is not reported as though it were: a routable literal publishes the corpus, while a hostname defers the question to :func:derive_host_allow_list, which resolves it and logs the verdict.

Parameters:

Name Type Description Default
host str

The address to bind, as typed. See :func:_is_loopback_literal for why a hostname is not resolved.

required
allow_remote_access bool

The operator's explicit acknowledgement (--allow-remote-access). It does not make the exposure safe; it makes it deliberate, and is answered with a warning naming exactly what is published — or, for a name, what would be.

required

Raises:

Type Description
ConfigurationError

host is not a loopback literal and allow_remote_access is False. No new exception type is introduced: a bind address is operator configuration, which is what :class:~groundkit.errors.ConfigurationError already means (ADR-0014 decision 9's "no new exception types").

Source code in src/groundkit/service/binding.py
def ensure_bindable_host(host: str, *, allow_remote_access: bool) -> None:
    """Refuse a non-loopback bind unless the operator acknowledged the exposure.

    Called before anything else in ``grk serve`` — before the index directory
    is opened and before a registry exists — so a refused host costs nothing
    and touches nothing.

    The refusal is literal-only and stays that way; see
    :func:`_is_loopback_literal`. What the acknowledgement *means* is not the
    same in both cases, though, so it is not reported as though it were: a
    routable literal publishes the corpus, while a hostname defers the question
    to :func:`derive_host_allow_list`, which resolves it and logs the verdict.

    Args:
        host: The address to bind, as typed. See :func:`_is_loopback_literal`
            for why a hostname is not resolved.
        allow_remote_access: The operator's explicit acknowledgement
            (``--allow-remote-access``). It does not make the exposure safe;
            it makes it deliberate, and is answered with a warning naming
            exactly what is published — or, for a name, what would be.

    Raises:
        ConfigurationError: *host* is not a loopback literal and
            *allow_remote_access* is ``False``. No new exception type is
            introduced: a bind address is operator configuration, which is
            what :class:`~groundkit.errors.ConfigurationError` already means
            (ADR-0014 decision 9's "no new exception types").
    """
    if _is_loopback_literal(host):
        return

    if not allow_remote_access:
        # ASCII only, for the reason `_EXPOSURE` gives.
        raise ConfigurationError(
            f"refusing to bind {host!r}: it is not a loopback address literal, and this "
            "service ships no authentication of any kind, so the bind is its only access "
            "control (ADR-0014 decision 7). Bind 127.0.0.1 or ::1 instead. A hostname such as "
            "'localhost' is refused here because only an address literal can be classified "
            "without a resolver whose answer can change, and that answer decides whether a "
            "corpus is published. --allow-remote-access accepts either kind, and they are not "
            "equivalent: a routable address publishes the corpus, while a hostname is resolved "
            "at serve time and keeps Host validation enforced if every answer is loopback "
            "(ADR-0024)."
        )

    if _is_address_literal(host):
        logger.warning(
            "Binding %s, which is not a loopback address, because --allow-remote-access was "
            "passed. Passing this flag publishes the corpus. %s",
            host,
            _EXPOSURE,
        )
        return

    logger.warning(
        "Binding the hostname %s because --allow-remote-access was passed. Whether that "
        "publishes anything depends on what the name resolves to, which this guard does not "
        "ask; the serve-time Host allow-list derivation resolves it and logs the verdict "
        "(ADR-0024). If it resolves anywhere outside loopback, passing this flag publishes the "
        "corpus. %s",
        host,
        _EXPOSURE,
    )