llm-ingestion-okf/docs/plan/mcp-bundle-population.md
Kjell Tore Guttormsen ddc8f76446 docs(mcp): Q2 gains upstream's tool surface as measured prior art
sources/base.py plus tools/source_tools.py is the same problem phase 5 option
(A) contemplates, already solved in ADK-specific form: a data system exposed as
model-callable tools. Recorded under Q2 because that question calls the tool
surface the real design work, and this is a worked example of it.

Three signals, each with the reason it is a signal: enumerate and read are
abstract while sample_rows defaults to None, so sampling is advisory; the
surface is per-concept, which is evidence that Door B's per-run collision gate
and index generation are the side that has to give; and two things not to copy
— an untyped dict[str, Any] return that only a model can interpret, and a find()
that linear-scans list_concepts() and so assumes total enumeration is cheap,
against this library's rule that nothing enumerates unless a profile says
derived.

Read at the pinned commit, not vendored.
2026-07-27 20:16:47 +02:00

107 lines
5.8 KiB
Markdown

# Phase 5 — MCP as a way to populate a bundle
**Status: scoped, not designed.** Committed by the operator 2026-07-27. This
document records the commitment, the fork it turns on, and the constraints any
design has to satisfy. It deliberately does not choose an implementation:
the fork below decides who owns the work, and choosing it wrongly is expensive
in a way writing it later is not.
## The fork — settle this first
"Support MCP to populate a bundle" names two different pieces of work. They
share a protocol and nothing else.
**(A) We are the MCP server.** An agent connects to us and calls tools —
`add_concept`, `list_bundle`, `generate_index` — and the bundle fills up through
our doors. The natural home is **Door B**, which already answers "here is
content, make it OKF": MCP would be a protocol surface over an existing door
rather than a new ingestion path.
**(B) We are an MCP client.** A manifest names an MCP server as a source, the
same way it names `file`/`sql`/`http` today, and Door A pulls concepts from it.
**Default reading, for confirmation rather than assumption:** the phrase "to
populate a bundle" points at (A). Recorded as a reading, not a decision — the
operator has not been asked which one, and the two have different owners.
## Why the fork decides the owner
(B) adds a **source type**, and the source type set is normative in commons'
`ingest-spec.md`. This repo implements that spec and does not edit it. So (B)
cannot start here: it starts as a request to commons, and it lands here only
after they have specified it. (A) adds no source type and no spec surface — the
concepts arrive through Door B exactly as dropped files do — so (A) is ours to
design.
That asymmetry is the whole reason this document exists before any code.
## Constraints any design inherits
None of these are negotiable by this feature, and each one has already refused
something:
1. **Exactly one runtime dependency, ever** (`llm-ingestion-guard`), enforced by
a packaging test. An MCP SDK would be the second. Either the protocol is
hand-rolled over stdlib JSON-RPC on stdio, or it lives behind an optional
extra the way binary parsers do in `[extract]` — never in the core install.
2. **No model calls anywhere in the run path.** MCP is model-facing, and that is
fine in exactly one direction: under (A) the model is the *caller* and we
stay deterministic tools. A design where this library calls a model to decide
what to write is out of scope, and would be a different product.
3. **The persist gate still applies.** Content arriving over MCP is untrusted by
construction. It goes through Door B's injected guard gate like every other
dropped file; MCP is a transport, never a reason to bypass a gate. Security
stays the guard's, as always.
4. **Network access is an explicit per-run opt-in.** A server listening on a
socket, or a client dialing one, is network. The existing fail-fast refusal
is the baseline, not something to reinvent.
5. **Determinism.** `ingested_at` is a required argument with no wall-clock
default. A protocol handler does not get to invent one because a call
arrived "now".
## Open questions
- **Q1 — (A) or (B), or both in order?** Blocks everything else.
- **Q2 — If (A): what is the tool surface?** Door B's public surface is
`process_inbox` over a directory. An MCP tool taking one concept at a time is
a different granularity, and the collision gate and index generation are
currently per-run rather than per-concept. This is the real design work.
**Prior art, measured 2026-07-27 at the pinned upstream commit.** Upstream has
already built this surface in ADK-specific form, and it is worth reading before
we draw our own. `sources/base.py` (38 lines, stdlib only) defines a `Source`
ABC; `tools/source_tools.py` wraps its methods as model-callable tools, which
`agent.py` registers. Three things in it are design signals rather than
incidental:
- **`list_concepts` and `read_concept` are abstract; `sample_rows` is optional
with a `None` default; `find` ships a default implementation.** So the
surface's obligations are *enumerate* and *read*, and sampling is advisory.
A tool surface that made sampling mandatory would be claiming more than the
problem needs.
- **It is per-CONCEPT** (`read_concept(ref)`), which is the granularity this
question is about. That an independent implementation landed on per-concept
is evidence that the per-run shape — Door B's collision gate and index
generation — is the side that has to give, not the tool surface.
- **What not to copy.** `read_concept` returns `dict[str, Any]`, untyped
because a model interprets it; we render bytes and cannot. And `find` is a
linear scan over `list_concepts()`, which assumes total enumeration is cheap
— colliding directly with this library's rule that nothing enumerates unless
a profile says derived.
Read, not vendored. The point is the shape of the question they answered.
- **Q3 — Transport.** stdio is the vendorable, zero-dependency answer and
matches how the marketplace consumes things. HTTP would be a second network
surface with its own gate question.
- **Q4 — Which half?** Phase 4 puts a Node/ESM half next to the Python one. MCP
servers in the plugin world are overwhelmingly Node. If (A) lands in Python
only, the second-brain consumers cannot use it; if it lands in both, the
"share contract and fixtures, not code" rule applies to the tool surface too.
- **Q5 — Does an MCP-populated bundle differ observably** from one Door B wrote
from files? It must not, or the transport has leaked into the artifact.
## Non-goals
- Reimplementing any security check the guard owns.
- Calling a model from this library, under any transport.
- Editing commons' source-type set locally to make (B) possible sooner.