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 |
default_top_k |
int
|
Applied when a request omits |
scan_limiter |
Semaphore
|
Bounds concurrent corpus-scale work to
:data: |
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 |
handler |
ToolHandler
|
Coroutine performing the operation. |
side_effect |
SideEffect
|
Always |
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
¶
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
282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 | |
handle_index_status
async
¶
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
handle_list_collections
async
¶
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
handle_search
async
¶
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
result_count ¶
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
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
¶
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: |
kind |
str
|
Stable machine-readable label. A client branching on this is not
parsing prose, which is the failure mode |
check_collection ¶
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
|
|
ErrorRendering | None
|
return without invoking any handler. |
Source code in src/groundkit/service/errors.py
map_exception ¶
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
unexpected_error_rendering ¶
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
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
¶
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 ¶
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 |
LOOPBACK_HOST_ALLOW_LIST
|
Returns:
| Type | Description |
|---|---|
FastAPI
|
A FastAPI application exposing exactly the operations in |
FastAPI
|
data: |
Source code in src/groundkit/service/api.py
140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 | |
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 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 |
Source code in src/groundkit/service/mcp_server.py
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 |
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
list_tool_definitions
async
¶
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 |
Source code in src/groundkit/service/mcp_server.py
run_stdio
async
¶
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
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 |
mcp_allowed_hosts |
tuple[str, ...]
|
|
mcp_allowed_origins |
tuple[str, ...]
|
|
trusted_hosts |
tuple[str, ...]
|
|
derive_host_allow_list ¶
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:
Hostvalidation 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 |
required |
Returns:
| Type | Description |
|---|---|
HostAllowList
|
An enforced :func: |
HostAllowList
|
cannot be shown not to be; :data: |
HostAllowList
|
is routable. |
Source code in src/groundkit/service/binding.py
567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 | |
ensure_bindable_host ¶
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: |
required |
allow_remote_access
|
bool
|
The operator's explicit acknowledgement
( |
required |
Raises:
| Type | Description |
|---|---|
ConfigurationError
|
host is not a loopback literal and
allow_remote_access is |