Skip to content

Add two read-only Kinbase tools to the MCP server - #62

Merged
jmc-wander merged 7 commits into
mainfrom
feature/kindex-kinbase-read-tools
Sep 24, 2026
Merged

jmc-wander merged 7 commits into
mainfrom
feature/kindex-kinbase-read-tools

Conversation

@shanebarakat

@shanebarakat shanebarakat commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

kinbase_sync already refreshes a repository’s own signed evidence. What an agent could not do was ask whether the repository has Company direction at all, or why one key reads the way it does.

kinbase_status answers the first. A withheld Company snapshot degrades a projection to repository evidence without failing, so a repository that lost Company and one that never had direction read alike until something asks.

kinbase_explain answers the second, for one exact key — the same call kinbase_sync already makes, so the path is already trusted.

What is deliberately absent

kinbase project. It generates the whole task brief and is the call that measurably improves plans, but it writes a query log and may submit an Unknown — projector.rs says so directly: "before a question or unknown is written". There is no dry-run flag. An agent calling it in a plan→evaluate→replan loop would turn the queue humans use for real gaps into question spam, and a tool that writes on read is not safe to retry, parallelise or cache.

test_read_only_tools_never_invoke_project asserts the argv never contains it, so the decision survives a future refactor.

Shape

Both are thin passes over the CLI’s --json, following sync_kinbase: shutil.which resolves the binary, a refusal comes back as the typed document Kinbase wrote rather than a raised exception that loses its code and remediation, and neither tool exposes a binary parameter — a tool caller does not name what runs.

Tests

Four added: the never-invokes-project guard, refusal-as-document, refusal without a binary on PATH, and the MCP wrappers returning typed errors.

  • test_kinbase_acceptance.py: 54 passed
  • MCP + transfer suites: 94 passed
  • Full suite: 2879 passed, 4 failures matching baseline exactly on an unpatched tree
  • Positive control: pointing read_status at project fails the guard test; reverting passes it

Also exercised against a live kinbased, not only stubs: kinbase_status returned certified with a valid certificate over 1099 observations, kinbase_explain returned a ratified ruling, and an unknown key came back as state: unknown rather than an error.

docs/index.html and the pinned count in test_release_metadata.py move 67 → 69.

Part of a set, but independent

This one stands alone and can merge whenever. The other two are wandercom/kinbase#24, which lifts kinbase's loopback restriction, and wandercom/kinbase-live#1, the Kubernetes deployment that waits on it. Neither is needed for this.

Addressed from review

"Read-only" was not accurate and the claim is withdrawn. kinbase status runs Kinbase's own due-maintenance sweep before it reports, closing apologies already past their deadline, which is a signed write. There is no flag to suppress it.

What I checked before deciding whether that disqualifies the tool: .kin is byte-identical after three consecutive status runs on a repository with nothing overdue. The write is bounded by what is already due rather than by how often it is asked, and a second call in the same minute writes nothing. That is the line between this and project, which records every query and may open a new Unknown per call — so the exclusion stands and the framing changes.

The helper is now _query rather than _read_only, and the tool docstring, the module docstring and the changelog all say what status does instead of claiming it does nothing.

test_the_query_tools_leave_the_repository_and_the_graph_untouched asserts the narrower contract Kindex can actually hold: across repeated calls no repository file is added, removed or rewritten by Kindex, and the graph gains no nodes.

@adaptcom adaptcom Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confidence Score: 3/5

Summary

Adds Kinbase status/explain tools; prior findings are resolved and 145 focused tests pass. Signed-maintenance behavior still requires Jeremy McEntire’s review before merge.

Important Files Changed

File Overview
CHANGELOG.md Documents query tools and maintenance side effects.
docs/index.html Updates the public MCP tool count to 69.
src/kindex/kinbase.py Adds unbounded status and bounded explain queries.
src/kindex/mcp_server.py Exposes status/explain tools and preserves typed refusals.
tests/test_kinbase_acceptance.py Covers refusals, command isolation, repository state, and timeout contracts.
tests/test_release_metadata.py Updates the expected tool count to 69.

↻ Re-run review · View in Adapt

Comment thread src/kindex/kinbase.py Outdated
def read_status(repo: str | Path, *, binary="kinbase") -> dict:
"""Certification, trusted fact count and open Unknowns for one repository."""
root = Path(repo).expanduser().resolve(strict=True)
return _read_only(["status", "--repo", str(root)], binary, READ_TIMEOUT_S)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kinbase status invokes emit_due_orphan_abandonments, signing repository events and closing overdue apologies; withhold kinbase_status until a no-maintenance path exists and test that signed state remains unchanged.

@shanebarakat
shanebarakat force-pushed the feature/kindex-kinbase-read-tools branch from 9dcda4f to 3632fae Compare September 23, 2026 14:05
kinbase_sync already refreshes a repository's own signed evidence. What an agent
could not do was ask whether the repository has Company direction at all, or why
one key reads the way it does.

kinbase_status answers the first. A withheld snapshot degrades a projection to
repository evidence without failing, so a repository that lost Company and one
that never had direction read alike until something asks.

kinbase_explain answers the second, for one exact key, which is the call
kinbase_sync already makes and therefore already trusted.

Generating a whole task brief stays outside the tool surface. kinbase project
writes a query log and may submit an Unknown, so an agent calling it in a loop
turns a human review queue into question spam; a test asserts the read path
never reaches it.
`status` runs Kinbase's due-maintenance sweep first, and that sweep signs
events. A deadline firing partway through would tear a write rather than
abandon a read: killed between the signed event and the ledger recording it,
the next call is free to re-emit an id that already exists.

The CLI imposes no such bound, so adding one here invented a failure mode
rather than contained one. Bounding it safely means crash-safe maintenance
inside Kinbase, which is not ours to add from here. `explain` keeps its bound
because it reduces one key and writes nothing.
READ_TIMEOUT_S stopped being used when status became unbounded, and its comment
claimed it covered explain, which uses its own sixty second bound. A constant
that names a thirty second limit nothing applies is worse than no constant.
It described READ_TIMEOUT_S and outlived it, landing above _query where it read
as documentation for the function. Both halves were wrong by then: status is
unbounded, and the explain bound covers one call rather than a whole sync.
@shanebarakat
shanebarakat force-pushed the feature/kindex-kinbase-read-tools branch from fda0c76 to bf5cc4f Compare September 24, 2026 14:13
@jmc-wander

Copy link
Copy Markdown
Contributor

Independent review — source and tests read directly, nothing inherited from CI or other reviewers.

First, a correction to the premise I was given: the conflict is CHANGELOG.md only, and it is trivial. docs/index.html and src/kindex/mcp_server.py auto-merge. Both sides insert a section under ## [Unreleased] (this PR ### Added, main ### Fixed from #60) — resolution is "keep both." This PR touches neither publish-mcp.yml nor CLAUDE.md, so it does not overlap #63 at all.

Tests pass on the branch: test_kinbase_acceptance.py 56 passed, test_release_metadata.py 7 passed.

Blocking

1. read_status runs an unbounded subprocess on the MCP event loop. kinbase.py:331 calls _query(...) with subprocess.run(timeout=None). FastMCP invokes sync tool functions inline on the asyncio loop (func_metadata.py:95 — return fn(**args), no to_thread), so a kinbase status that hangs on its network reach to Company blocks every other kindex tool for that client — search, add, task — permanently. MCP cancellation cannot interrupt a blocking sync call.

I took the rationale in b4c1887/c34d931 seriously (deleting READ_TIMEOUT_S = 30 because "a timeout firing partway through would tear a signed write") and I don't think it holds: subprocess.run's timeout SIGKILLs the child exactly as a client quit, an MCP restart, or a power loss does. Kinbase's maintenance is either crash-safe or it isn't. If it isn't, removing the bound doesn't prevent the tear — it makes it rarer while guaranteeing an unrecoverable hang. The docstring concedes this ("Bounding this safely means crash-safe maintenance inside Kinbase"). This trades a bounded, recoverable failure for an unbounded, unrecoverable one to paper over another system's durability bug.

Side effect worth catching: deleting READ_TIMEOUT_S silently raised the interactive explain bound from 30s to EXPLAIN_TIMEOUT_S = 60 — a constant the deleted comment said was "sized for a whole reduced sync rather than one answer."

2. A Kinbase refusal is indistinguishable from success, and is recorded as a healthy call. kinbase.py:310-311 returns the refusal envelope verbatim; mcp_server.py:502 dumps it unchanged. Probed live:

kinbase exits 1, stdout {"error":{"code":"COMPANY_UNREACHABLE"}}
  -> tool output: {"error": {...}}          <- no "ok" key
  -> _health_outcome(output): "success"
kindex-side failure
  -> tool output: {"ok": false, "error": {"code":"kinbase_status_refused"}}
  -> _health_outcome(output): "failed"

Two incompatible shapes from one tool. _health_outcome's string branch (mcp_server.py:194-200) only tests parsed.get("ok") is False; the "error" in result test lives in the dict branch (line 192), which tools never reach because they return str. So: Company unreachable for a week, every call refuses, the health ledger records 100% success, nothing escalates. A caller doing result.get("ok", True) reads COMPANY_UNREACHABLE as a clean status.

That is the tool's own stated purpose inverted — "a repository that lost Company and one that never had direction read alike until this is asked" — reintroduced at its output boundary. A disposition with no signal.

Non-blocking

3. stderr is discarded. kinbase.py:307 calls _loads(result.stdout) before checking the returncode, and never reads result.stderr. A kinbase writing kinbase: config missing to stderr and exiting 3 surfaces as {"ok": false, "error": {"message": "Expecting value: line 1 column 1 (char 0)"}}. Fails closed, which is right, but the operator gets a parser artifact instead of the remediation Kinbase wrote — the exact loss _query's docstring says it exists to prevent.

4. Unbounded capture and response. kinbase.py:300 captures stdout with no size limit; mcp_server.py:502/526 dumps with no truncation. The sync path bounds event files at 64KiB; the read path bounds nothing, and search/context have token tiers while this injects a whole envelope verbatim into model context.

5. Naming, not a defect. The read_status/kinbase_status docstrings state plainly that Kinbase closes overdue apologies (a signed write) on every status. The PR title and kinbase_explain's bare "Read-only" don't carry the caveat. No tool emits MCP readOnlyHint, so no machine-readable false claim is made — but note graph constraint C004 (6d2d468acb27) records "All agent-facing tools (project, explain, ...) are read-only" and component mcp-server (703680ae8b6a) says "read-only kinbase project/explain". This PR asserts project writes. Either C004 is stale or the rationale is wrong; one needs correcting.

Things I expected to find and could not

  • Classification / exfiltration. Tried to build the concrete call; couldn't. _node maps store_kind→audience only on graph import, and audience governs Kindex's export boundary rather than in-session visibility. The pre-existing kinbase_sync already pulls the same content for any --repo a caller names, so these tools grant no access it doesn't. Kinbase stays the authority per repo+decision. No new exposure.
  • Credential leakage. I expected the read tools to bypass redact(). They don't — _safe_output (mcp_server.py:170) applies redact_serialized to every tool's string output.
  • Indirect writes. None. _get_store() isn't even called; verified by test_the_query_tools_leave_the_repository_and_the_graph_untouched.

Tests

Better than happy-path, and they test the promise where Kindex owns it: argv never contains project; binary is not exposed as a tool parameter (checked via inspect.signature); repo and graph byte-identical after 3x each; timeout bounds asserted per command. Two gaps: the "untouched" test uses a stub kinbase, so it proves Kindex writes nothing rather than that the pair is read-only end-to-end (the docstring is honest about this); and the refusal test asserts at the kinbase module layer, never at the MCP output layer — which is exactly where finding 2 lives.

What this gets right

The tool boundary is drawn in the right place for a stated reason: project is excluded because it writes a query log and can open an Unknown that a looping agent turns into queue noise. binary is deliberately not a caller-supplied parameter, so nothing can name what executes. Refusals are preserved as typed documents instead of flattened to an exception string. The docstrings disclose the one impurity rather than hiding behind the label. The tests assert invariants, not behavior. Docs, tool count, and CHANGELOG move together. And it fills a real gap — a repo that lost Company and one that never had direction were previously indistinguishable.

Verdict: CHANGES_REQUESTED

(1) and (2) are both fail-silent defects in a tool whose entire purpose is making a silent degradation visible. The CHANGELOG conflict is a one-minute fix.

read_status ran with no deadline on the theory that a timeout firing mid-sweep
would tear a signed write. The trade was the wrong way round. The deadline
SIGKILLs the child exactly as a client quit, an MCP restart or a lost machine
does, so the tear is reachable either way and the bound only changes how often
it is reached.

What the bound removes is the unrecoverable case. FastMCP calls a sync tool
inline on the event loop, so an unbounded status against an unreachable Company
does not stall one tool, it stalls every kindex tool for that client with no
cancellation path.

STATUS_TIMEOUT_S is thirty seconds, below the explain bound, and the test now
asserts each read carries its own.
Kinbase refuses with a typed envelope on stdout carrying `error` and no `ok`,
and that envelope was returned verbatim. One tool therefore had two shapes: a
caller reading `ok` saw COMPANY_UNREACHABLE as a clean status, and the health
ledger's string branch, which tested only `ok is False`, recorded an unreachable
Company as a healthy call for as long as it lasted.

The refusal now keeps Kinbase's own code and remedy and gains the disposition
every other tool carries. The ledger's string branch also tests `error`, which
its dict branch already did, so no tool can report a refusal as success.

The test asserts this at the MCP output layer, which is where the disposition is
read from and where the existing refusal test stopped short.
Thirty seconds was inherited from the constant this replaced, which is not a
derivation. Measured: `status` against a 0.36 MB store takes 655-884 ms over ten
runs, and the cost is linear in store size, so the 5 MB that the deploy notes
name as the point where the query shape must be fixed lands near nine seconds.
A 20 MB store extrapolates past the bound, which is the query shape failing
loudly rather than the bound being wrong, and the note says so along with the
fact that it is one measured point.

The rationale for leaving it unbounded is also weaker than it read. It feared
the next call re-emitting an event id that already exists, but Kinbase appends
with INSERT OR IGNORE against an event_id primary key, so that is the case the
insert absorbs. The tear is a no-op, not corruption.

Both guards were wider than their reason. The tool boundary tested for `error`
and would have overwritten a Kinbase envelope that carried its own `ok`; it now
supplies a disposition only where Kinbase omitted one. The health ledger tested
for the presence of an `error` key, so a payload carrying an explicit null would
have counted as a refusal; it now tests the value.
@shanebarakat

Copy link
Copy Markdown
Contributor Author

Both blockers fixed, and two pushbacks.

The bound is measured now, not inherited: 655-884ms over ten runs against a 0.36MB store, linear in store size, so the 5MB in the deploy notes lands near 9s. A 20MB store extrapolates past it, which is the query shape failing loudly rather than the bound being wrong. One measured point, not a second measurement.

The explain bound didn't move. read_explain passed EXPLAIN_TIMEOUT_S before and after; it was the deleted comment that wrongly claimed READ_TIMEOUT_S covered it, which is what 490b511 says.

The torn-write concern doesn't hold at all, rather than partly. append_event is INSERT OR IGNORE against an event_id primary key, so the re-emitted id the original rationale feared is exactly what that insert absorbs. The tear is a no-op, not corruption. That's the real reason the bound is safe.

Finding 2 is fixed at the MCP output layer, the gap you named. Narrowed both guards a little past the ask: the boundary only supplies a disposition where Kinbase omitted one, and the ledger tests the error value rather than key presence.

@jmc-wander

Copy link
Copy Markdown
Contributor

Re-reviewed at head 4bf175b. Both blockers are fixed, with forcing tests that assert the behaviour rather than the constant. Merging.

The unbounded status subprocess — kinbase.py:293 STATUS_TIMEOUT_S = 30, applied at :350. What I appreciate is that the bound is measured, not guessed: the docstring cites 655–884 ms over ten runs against a 0.36 MB store and extrapolates the failure point. And the old rationale is reversed on the right ground rather than waved away — a SIGKILL tear was already reachable via client quit or a lost machine, and INSERT OR IGNORE against the event_id primary key absorbs the re-emitted id the old reasoning feared. test_each_kinbase_read_carries_its_own_bound asserts the actual timeout= value reaching subprocess.run, not that a constant exists.

One thing to keep in view, not a regression: the call is still inline on the event loop, so the worst case is now a bounded 30 s stall of every kindex tool (60 s for explain, kinbase.py:162) rather than an indefinite one. That's the first of the two remedies I offered and it's sufficient; moving it off-loop would make it a non-event.

The refusal-reads-as-success shape — fixed at both layers, which is the right call. mcp_server.py:507 and :537 normalise at the tool boundary, preserving Kinbase's own code and remedy and adding only the disposition; mcp_server.py:200-201 widens _health_outcome's string branch to parsed.get("ok") is False or parsed.get("error") is not None. test_a_kinbase_refusal_reaches_the_mcp_boundary_as_a_failure feeds a real CompletedProcess(returncode=1, stdout='{"error":{"code":"COMPANY_UNREACHABLE"...}}') through mcp.kinbase_status and asserts all three properties — ok is False, the original code survives, and _health_outcome returns "failed". That's the guard watching the prohibited outcome on the channel the code actually uses.

CHANGELOG resolution is clean. git diff origin/main..pr62 -- CHANGELOG.md is purely additive: +14/-0, one ### Added block under [Unreleased], with main's ## [0.44.1] and its ### Fixed entries from #60 intact below it.

64 tests pass (PYTHONPATH=src).

One line worth fixing on the way past

_health_outcome now asks the same question two ways. mcp_server.py:192 (dict branch, pre-existing) tests "error" in result — key presence. mcp_server.py:201 (string branch, this PR) tests parsed.get("error") is not None. So {"ok": true, "error": null} is "failed" as a dict and "success" as a JSON string. The same split exists between kinbase.py:325 ("error" in envelope, and note it returns before the returncode check at :328) and mcp_server.py:507 (is not None) — an envelope carrying "error": null with a non-zero exit would be returned by _query, skipped by the normaliser, and scored success.

I checked whether this is live and it isn't: crates/kinbase/src/error.rs and http.rs emit {"error": ...} only on refusal, never "error": null. So it's latent, not a bug today. Flagging it because it's the same "one tool, two shapes" failure e33628e was written to eliminate, reintroduced one line from the fix.

Still open from the first review, none blocking

stderr is still discarded — kinbase.py:315-317 captures it, but :328 raises RuntimeError(f"Kinbase {argv[0]} exited {result.returncode}") with the code only, so the operator still gets an exit code instead of the remediation Kinbase wrote. Stdout capture and the tool response are still unbounded. And the C004 conflict is unresolved: the constraint records all agent-facing tools including project as read-only, while this PR asserts project writes — one of them needs correcting, and I couldn't re-verify C004 itself because node 6d2d468acb27 isn't reachable from this graph.

Merging on the strength of the two blockers being genuinely fixed.

@jmc-wander
jmc-wander merged commit 3d2ae13 into main Sep 24, 2026
1 check passed
@jmc-wander
jmc-wander deleted the feature/kindex-kinbase-read-tools branch September 24, 2026 19:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants