MCP tools
Protocol version: theurian/v1. Transport: Streamable HTTP at
http://127.0.0.1:7419/mcp.
Today, Core registers ten callable MCP tools:
knowledge.searchknowledge.getknowledge.statusknowledge.proposeChangeknowledge.generateMigrationDraftproject.listreview.findingsreview.generateKnowledgeCandidatereview.searchsystem.capabilities
system.capabilities is the runtime boundary for clients. In this build it
reports reviewFindings: true and reviewIngestion: true — review.findings
and review.search are both callable — and writeTools: true, because the
write-intent tools knowledge.proposeChange and knowledge.generateMigrationDraft
are registered (ADR-0032); they emit a proposal a human reviews and merges and
reach no approved-state write, which is what the capabilities note says.
review.generateKnowledgeCandidate joined that surface additively (ADR-0033) and
moved no flag: writeTools answers whether any write-intent tool exists, not
how many. Only
traceability: false still marks a tool described below as designed protocol
shape rather than a callable tool in the current server. reviewIngestion: true
is a statement about callable tools and nothing
wider: it says an ingestion call surface exists that a client may call, published
beside reviewIngestionScope: "public-allowlisted", and it does not say a client
may start an ingestion run — no tool spawns gh, and a fetch stays an operator's
act through theurian review ingest (see below).
Every project-scoped call names its project
{
"projectId": "backend-service"
}
projectId is required on every project-scoped tool that ships today:
knowledge.search, knowledge.get, knowledge.status, knowledge.proposeChange,
knowledge.generateMigrationDraft, review.findings,
review.generateKnowledgeCandidate and
review.search. Omitting it is a
validation error, never a fallback to "the last one used". With ten subagents
sharing one daemon, an implicit default resolves one agent's query against
another agent's project (ADR-0002).
snapshotId is response provenance today. knowledge.search returns it in the
retrieval envelope, and knowledge.status returns the same state as
stateHash, so a caller can compare which canonical state answered. Passing a
snapshotId back as a request pin is designed behavior, not implemented in the
current MCP tools.
agentId and taskId are designed proposal provenance fields. Theurian does
not authenticate agents, and no MCP proposal tool accepts them today.
All three optional fields are admitted by the published input schemas and
read by nothing. They belong to the shared context every project-scoped tool
references, so a call that sets snapshotId, agentId or taskId passes
validation and is then answered exactly as if it had not set it — no refusal, no
signal. Which of the two cures that gets, implementing them or withdrawing them
from the contract, is
#665.
Schema: tool-context.schema.json.
Every tool call is validated against its published input schema
Every tool listed above has a published input schema under schemas/mcp/, and
the daemon validates each tools/call against that tool's schema before the
call reaches the tool (SEC-12,
ADR-0031). The
check runs in an MCP server middleware, above the SDK's argument coercion, which
is the only tier that still sees the keys a caller actually sent.
| Tool | Input schema |
|---|---|
knowledge.search |
knowledge-search-input.schema.json |
knowledge.get |
knowledge-get-input.schema.json |
knowledge.status |
knowledge-status-input.schema.json |
knowledge.proposeChange |
knowledge-propose-change-input.schema.json |
knowledge.generateMigrationDraft |
knowledge-generate-migration-draft-input.schema.json |
project.list |
project-list-input.schema.json |
review.findings |
review-findings-input.schema.json |
review.generateKnowledgeCandidate |
review-generate-knowledge-candidate-input.schema.json |
review.search |
review-search-input.schema.json |
system.capabilities |
system-capabilities-input.schema.json |
Every project-scoped tool reaches projectId and the three optional context
fields through a $ref to tool-context.schema.json rather than restating them,
so every tool agrees about projectId by construction. project.list and
system.capabilities take no arguments, and their schemas say exactly that: an
empty properties, closed.
Unknown keys are refused, not dropped. A key a tool's schema does not name is
rejected and nothing runs. Sending a forward-looking field to see whether this
build supports it does not work, and is not meant to: a server that quietly
discards a parameter leaves a client believing it asked for something it did not
get. system.capabilities is where a client learns what this build supports.
A refusal arrives in the same shape as a refusal raised inside a tool body —
content plus isError: true, and the same key set — so a caller cannot tell
which tier answered from the shape, only from what it says. It names the offending
key path and the constraint that rejected it, and it never reproduces the value
the key carried. It carries no code from the Errors table.
A tool this daemon publishes no input schema for is refused at dispatch rather than served, so the control covers whatever set is registered rather than the set someone remembered to enumerate.
What the schemas constrain, and what they leave to the tool. Shape, key set
and length. Vocabulary and numeric range stay tool-level refusals on purpose: a
published enum answers "does not satisfy" where review.findings names the
closed set it accepts and tells you to omit the filter, and a published range
would turn knowledge.search's deliberate clamps on limit and maxTokens into
wire refusals. So a schema-valid request can still be refused by the tool, and
that is by design.
Three behaviour changes a caller can observe
| Before | Now |
|---|---|
| An unknown or extra key on any tool was silently dropped by the SDK's argument model, and the call was served | The call is refused, with the key named |
project.list and system.capabilities take no arguments, and anything sent with them was ignored |
Anything sent with them is refused |
A knowledge.search query longer than MAX_QUERY_CHARS (2,000 characters) was truncated to that length and the prefix searched |
The call is refused at the wire and nothing is searched |
The third is the one that changes an answer rather than a silence, and the reason
it refuses instead of truncating is recorded on the schema's own query
description: truncating a query changes what was asked, and the caller cannot
tell from the response that it happened. Clamping limit or maxTokens changes
only how much comes back, so those stay clamps. The handler's own truncation
stays below this surface as a backstop and is unreachable through this contract.
Knowledge
| Tool | Status | Purpose |
|---|---|---|
knowledge.search |
Shipped | Hybrid-capable search with provenance and trust labels; falls back to substring retrieval when no index can answer |
knowledge.get |
Shipped | Fetch one knowledge item's current revision, with provenance |
knowledge.status |
Shipped | Canonical state hash, surfaceable item counts, applied-migration count, schema version, and optional integrity signal |
knowledge.getContext |
Planned | Assemble a token-budgeted context pack |
knowledge.trace |
Planned | Follow relations from an item |
knowledge.listChanges |
Planned | What changed between two snapshots |
knowledge.checkFreshness |
Planned | Which knowledge is outside its validity window |
knowledge.proposeChange |
Shipped write-intent | Draft a content change (a body and its revision) as a proposal; no approved-state write (ADR-0032) |
knowledge.submitFeedback |
Planned | Record retrieval quality signals |
knowledge.generateMigrationDraft |
Shipped write-intent | Draft a migration document as a proposal; the operations path, v1 admitting ten operation kinds; no approved-state write (ADR-0032) |
Write-intent tools do not write approved state
The write-intent tools are registered — two on the knowledge surface (ADR-0032)
and review.generateKnowledgeCandidate on the review one (ADR-0033). They reach
no approved-state
write: each holds a draft-only facade whose reachable surface is the two draft
entries alone, so approval stays a human merging a pull request (ADR-0013). They
produce proposal directories like this shape:
.theurian/proposals/<proposal-id>/
├── <migration-id>-<slug>.yaml # schema-valid and directly applicable
├── knowledge/... # content files, matching the knowledge format
└── evidence.json # anchors and the reasoning trail
There is no MCP path to approved state — not a flag, not a permission.
system.capabilities reports writeTools: true because the write-intent tools
are registered (ADR-0032), and the control that holds "no tool reaches approved
state" is that each is handed a draft-only facade whose reachable surface is the
two draft entries alone, so neither accept nor _commit is reachable from a
tool (ADR-0032 decision 8); a
second test enumerates every registered tool and asserts none reaches a canonical
write (ADR-0013). Proposals can be
drafted over MCP by knowledge.proposeChange, knowledge.generateMigrationDraft
and review.generateKnowledgeCandidate, or at the CLI: theurian propose drafts one,
and theurian propose accept moves the files into place. The intended approval
path is human review and merge in Git; Core does not verify that a migration was
merged before theurian migrate apply reads it.
Result shape
{
"itemId": "architecture.auth-policy",
"revisionId": "01K1DEFREV1234567890ABCDEF",
"title": "Authentication and authorization policy",
"excerpt": "The gateway verifies the request signature before any handler runs ...",
"contentType": "text/markdown",
"status": "approved",
"trustLevel": "reviewed",
"sensitivity": "internal",
"freshness": { "revisionCreatedAt": "2026-08-01T09:00:00+09:00", "isWithinValidity": true, "ageDays": 12 },
"sourceAnchors": [
{
"provider": "git",
"sourceUri": "git://backend/architecture/auth-policy.md",
"repository": "backend",
"commitSha": "a1b2c3",
"filePath": "architecture/auth-policy.md",
"lineStart": 1,
"lineEnd": 42
}
],
"contentClassification": "untrusted-knowledge",
"mayContainInstructions": true,
"executable": false,
"fusedScore": 0.87,
"foundBy": ["lexical", "summary"],
"raptorPath": [
{ "nodeId": "3f9a1c...", "level": 2, "title": "Backend architecture — gateway, caching, and queueing policies" },
{ "nodeId": "7c1b04...", "level": 1, "title": "Authentication and authorization policy" }
]
}
sourceAnchors is always present and occasionally empty: an empty array means the
revision declares it originates in Theurian rather than in a repository — the one
case the domain's INV-8 allows — and a result with no route back to its origin
cannot be constructed at all. fusedScore and foundBy appear together, on the
ranked path only. raptorPath appears only over a --raptor index — the hit's
summary ancestry, catalog root to leaf, one {nodeId, level, title} per node,
level descending 3→1 (ADR-0008 decision 8). snapshotId and indexBuildId are
provenance too, but they belong to the response's retrieval envelope, not to a
hit: one query is answered from one canonical state and one index build, so a
per-hit copy could never differ between hits (FR-R5).
Schema: retrieval-result.schema.json.
asOf pins a search to a moment
knowledge.search takes an optional asOf: an RFC 3339 timestamp with any
explicit offset, e.g. 2026-08-01T00:00:00Z or 2026-08-01T09:00:00+09:00 —
not only Z. Omit it and nothing changes —
every surfaceable item is a candidate whatever its declared validity window,
exactly as every prior release of this tool already behaved. Pass it and an
item outside its validFrom/validTo window at that moment is excluded from
results, and every returned hit's freshness.isWithinValidity is computed
against that same moment rather than against real time.
It is a refinement, not a default filter (FR-R1,
#63 phase 2). A permanent
filter was rejected: it would make isWithinValidity constant-true on a
healthy index — a published field that can never read false is not a field —
and it would give the ranked path a stale-index statistics residual with no way
to turn off. It is also not a withholding: everything one call excludes is
returned to the same caller by the identical query with asOf omitted, so no
response field here can carry information the caller could not already obtain
directly, and none of the disclosure guarantees this page states elsewhere for
a document a caller may not read apply to it.
knowledge.get does not take asOf. It names an item by id, and refusing to
resolve an id the caller already holds — on the grounds that the item is not
current at some other moment — would be a worse answer than the one it
already gives: freshness.isWithinValidity: false on the current revision,
computed against real time, exactly as before this parameter existed.
An unparseable asOf is a clean ToolError naming the fix, never a traceback.
knowledge.status
Six keys, all six always present — and a seventh, integrity, only when a
bounded damage check fired (below).
The contract is
knowledge-status-response.schema.json,
which also records why two of them are what they are.
{
"projectId": "demo",
"stateHash": "4e640de8baeb1f70e293d88c0b1160f15e6d02df676574937a9557ac8f6d87af",
"itemCount": 1,
"itemsByStatus": { "approved": 1 },
"appliedMigrations": 1,
"schemaVersion": 3
}
Captured from a project the real CLI built, not written by hand. schemaVersion
is 3 since #30 PR2 added the project_integrity table the damage check reads;
a state hash covers the schema version (ADR-0017), so a reader's own stateHash
differs from this one whatever else matches.
| Key | Meaning |
|---|---|
projectId |
Echoed from the request. Required, like every project-scoped tool |
stateHash |
Which canonical state the counts were read from. Byte-identical to the retrieval.snapshotId a knowledge.search answered from that state publishes, so the two can be compared without a second call (FR-R5) |
itemCount |
The sum of itemsByStatus, and deliberately not the number of items in the store |
itemsByStatus |
How many items hold each status a caller may see. A status with no items is absent rather than present with a zero, so {} is valid and expected |
appliedMigrations |
How many migration files this project has applied. Files, never items. Read from the active pointer's own migrationCount, so it cannot shrink when the state database loses migration rows — any difference between the two is reported as integrity instead (#30). It is the pointer's number, not a measurement of the rows: if the pointer is itself wrong, this field is wrong with it |
schemaVersion |
The canonical store's SQLite schema version — not protocolVersion, and not the retrieval index's schema version |
The counts report nothing about withheld content, not even a total.
itemsByStatus covers approved, draft and proposed only, and itemCount
is the sum of that breakdown rather than the size of the store, so a project
whose items are all retired answers {} and 0 — the same answer a project
holding nothing gives. Publishing the true total beside a filtered breakdown
would hand the withheld count back by subtraction, which is the question
knowledge.get refuses to answer when it declines to distinguish a withheld id
from an absent one (SEC-13, and T-17 in
the threat model).
That is a claim about the counts and not about the whole response.
stateHash and appliedMigrations both move when a migration creating only
withheld items lands, but on different triggers: stateHash moves for any change
to canonical state, appliedMigrations only when a migration is added — an edit
to an existing one moves the hash alone. Neither can be made to name a status, an
id or a body, and this tool takes only projectId, so no request parameter
reaches either. The per-field reasoning is in the schema.
Index state and proposal ages are not in this response. The table above
describes the tool this page is a contract for; what ships today is the six keys
here, plus the conditional integrity key below.
Damage is reported through a present-only integrity key
knowledge.search, knowledge.get and knowledge.status each carry an optional
top-level integrity object (#30):
{
"integrity": {
"damageDetected": true,
"remedy": "Run `theurian migrate apply` to rebuild the derived state from the Git-tracked migrations. If this signal persists, delete `.theurian/state/` and run `theurian migrate apply` again, then `theurian index build` to restore ranked retrieval; the state is derived, so nothing is lost."
}
}
It is present only when a bounded check detected a discrepancy. Its absence
asserts nothing — not "verified clean", and a client must not display it as
one. There is no damageDetected: false form and there will not be one: the
check is incomplete by design, so a false token would claim more than the
product knows, where an absent key claims nothing. damageDetected is therefore
always true when the key is present; branch on the key, not on its value. This
is the same present-only shape raptorPath already uses (ADR-0008 decision 8).
What today's check measures, and the whole of it: two counts, each compared against a record of what it should be.
| Compared | The live number | The record it is checked against |
|---|---|---|
| Migrations | this project's rows in migration_history |
the migrationCount in the active pointer that chose the database |
| Surfaceable items | this project's knowledge_items whose status is approved, draft or proposed |
project_integrity.expected_surfaceable_count, written by theurian migrate apply inside its own write transaction |
Either pair disagreeing sets the key. The state database is immutable once built,
so both pairs agree on a healthy project and a difference in either direction is
damage: fewer when a row is lost or falls out of a WHERE (a sentinel in
migration_history.project_id, knowledge_items.project_id or
knowledge_items.status), more when another project's rows reach this one. This
is a change in how many surfaceable rows there are, not in which surfaceable
status a row holds: both counts include approved, draft and proposed alike,
so a row moved from one of them to another leaves the counts equal and sets no
key, even though the default answer surfaces only a subset of those statuses and
can shrink — a recorded integrity residual, not a disclosure, since the row is
caller-readable at either status. A
missing project_integrity row is damage too rather than "not recorded": every
database this build opens declares schema version 4 or is refused unread, and
every apply that creates a database or applies a migration records the count, so a
readable database with no record has lost one.
The first comparison is why knowledge.status no longer answers with an
appliedMigrations that has shrunk. The second is what makes a response's own
emptiness visible, and the corruption sweep pins it against the real tools over a
real damaged database: a sentinel in knowledge_items.project_id gets count: 0,
results: [] from knowledge.search and itemCount: 0 from knowledge.status
with this key present, where before PR2 it got those same numbers alone.
It still does not detect a corruption that leaves the row inside both counts.
A sentinel in knowledge_items.item_id keeps the row's project_id and its
status, so neither count moves while the item → revision pointer
knowledge.search walks is broken: the tool answers with one result fewer — count:
0, results: [] when it was the only match — with this key absent and nothing
else on the response saying otherwise, since retrieval.stale reports false on
the ranked path and null on the unranked one and neither reports damage. That is
the single member of UNDETECTED_UNDERREPORT in
tests/integration/test_canonical_store_corruption.py, an exact set: a second
position appearing there is a test failure, not an expectation to update. A
different pointer fault would disclose rather than under-report — an item whose
current_revision_id names another item's revision, served as the wrong item's
body — and that one is refused at read time by the item → revision consistency
guard (#24, #30), not by this count. Neither
count is a checksum, so two damaged cells that cancel out are invisible, as is a
corrupt title or body the response hands over directly.
A corrupt status cell reaches the three tools differently, and a client
should expect all three answers. Measured on a sandbox project, one approved
item's status overwritten: knowledge.status answers a shrunken itemCount
with this key present; knowledge.search answers count: 0 with the key present
when the project has no published index and the substring fallback is answering,
and refuses when a published index makes the ranked path answer, because the
canonical gate parses each candidate's status and an uninterpretable one raises
rather than being skipped; knowledge.get refuses for the same reason as soon as
it reads that row. The refusals are the state-database message naming
delete-and-rebuild, not the integrity key.
The remedy names a fallback because the first command does not cure every
shape. The state database is derived and Git-ignored
(ADR-0004), so nothing authored is
lost by rebuilding it. theurian migrate apply is the cheap cure and comes first:
measured, it clears the key for a lost row, for a sentinel in
migration_history.project_id, and for a pointer that over-counts. It clears
nothing for a surplus row — the direction != deliberately catches — because
every authored migration is already applied, so the command exits 0 and the key is
still there on the next call. Deleting .theurian/state/ makes the following
apply rebuild the database, and theurian index build is named third because that
deletion takes the published retrieval index with it: measured, retrieval.indexed
is false with fallbackReason: "no-index" after the second step and true again
after the third. Following the string token by token takes a surplus row from
integrity present to absent with ranked retrieval intact. The efficacy is
measured, not yet pinned by a test.
It clears nothing for a damaged item count either, and that is deliberate rather
than an oversight: an apply with nothing pending records no new expected count,
because re-recording it from the damaged state would clear the signal without
repairing anything. The second step is what cures that shape
(test_a_pending_free_apply_does_not_re_record_over_a_damaged_state, with
test_an_apply_that_changes_the_store_records_the_new_count holding the other
direction).
knowledge.get refuses with a message rather than a payload when an item cannot
be returned, so it carries the distinction in the text: over detected damage it
reports a project that "could not be fully read: its derived state disagrees with
its own records about what it holds", instead of the message it gives for an item
that is simply not present. A withheld id and an absent id still get the same
message as each other (SEC-13) — what changed is that "the state disagrees with
its own records" is no longer reported as absence.
Both directions are pinned, since either alone is satisfied by a tool that says
one thing always
(test_an_absent_item_over_a_damaged_state_is_refused_as_damage_not_absence,
test_an_absent_item_over_a_healthy_state_is_refused_as_absence).
The reasoning, the measurements and what remains uncovered are in the threat model under T-17.
Review
Two review tools are shipped, and they read two different corpora.
review.findings serves the Review-Finding: trailers a project's own git
history carries, landed by theurian findings build and announced as
reviewFindings: true
(ADR-0029).
review.search serves the review evidence under .theurian/review/ — pull
requests, review submissions and review threads — read back through the store
theurian review build rebuilds
(ADR-0030 decision 6).
theurian review ingest lands that evidence from GitHub, and a clone can
deliver it too: .theurian/review/ is source rather than derived state and is
deliberately not git-ignored, so a project may commit its evidence and the build
projects whatever is there. The provenance check on this path is on the store
and answers "did this installation build it", never "who wrote the records"
(threat model T-24); every served row rides under
the SEC-15 triple whatever its origin.
The two flags stay separate for the reason they always were: reviewFindings
promises an offline read of local git trailers, and reviewIngestion is the one
whose surface reaches GitHub-sourced content. reviewIngestion reports true
from this slice, and it is read as narrowly as its history requires. It never
meant "this build can reach GitHub" — the fetch path shipped in slice 1, with
SEC-10's repository allowlist read and enforced before any process is spawned,
while the flag stayed false. It never meant "evidence lands on disk" — slice 2
shipped theurian review ingest, and the flag stayed false for the same
reason. What it reports, and all it has reported, is the MCP-callable surface:
an ingestion call surface exists that a client may call, which review.search
now is. It does not say a client may start an ingestion run; no tool spawns
gh, ADR-0013
keeps write intent off this surface, and a fetch is an operator's act through the
CLI verb.
It is published with reviewIngestionScope: "public-allowlisted" and never
one without the other (ADR-0030 decisions 2 and 6): a true with no scope tells a
client that ingested review content is reachable and omits the half that decides
how to treat it. The scope is the narrow claim ADR-0030 makes — no
advisory-private GitHub surface is ingested, and every record theurian review
ingest landed was visible to the public repository's audience at the moment it
was ingested — not the wider and false one that a public repository cannot carry
sensitive content. The tense is load-bearing, and an edit and a delete are not the
same case: an upstream edit reaches Theurian's copy on the next theurian
review ingest run whose window covers the record, which refetches it, rewrites
the file and counts it as updated; an upstream delete does not, because
ADR-0030 decision 3 makes the
files durable precisely so a deleted comment is not erased locally. The manual
remediation — delete the evidence file, rebuild the store — is that second case's.
The value is a build constant, identical in every deployment, which is what makes
it publishable on a surface that resolves no project.
It is a statement about ingestion, not an inventory of .theurian/review/:
that directory is source and is not git-ignored, so a clone can carry evidence a
repository author wrote, and nothing in this value or in a review.search
response tells the two apart (threat model
T-24, an accepted residual).
| Tool | Status | Purpose |
|---|---|---|
review.findings |
Shipped | Landed Review-Finding: trailers, filtered by reviewer, severity, commit or text |
review.search |
Shipped | Review evidence under .theurian/review/, filtered by repository, pull request, author, file, thread state or literal text |
review.getThread |
Planned | One thread with comments and resolution |
review.findSimilar |
Planned | Threads resembling a described situation |
review.getDecisions |
Planned | Decisions reached in review |
review.generateKnowledgeCandidate |
Shipped write-intent | Generalise one ingested review thread into a proposal, against a verified promotion gate; no approved-state write (ADR-0033) |
review.listUnresolved |
Planned | Open threads |
The designed review.findSimilar tool is the one expected to change outcomes:
it would answer "has this come up before?" before an agent reimplements something
the team already rejected.
review.findings
Three keys, all always present. The contract is
schemas/mcp/review-findings-response.schema.json.
{
"count": 1,
"truncated": false,
"findings": [
{
"commitSha": "3e45c7b…",
"position": 0,
"reviewer": "adversarial",
"severity": "HIGH",
"findingText": "byte-identical body accepted under a second item id",
"provider": "git",
"sourceUri": "3e45c7b…",
"committedAt": "2026-08-28T09:14:22.000000+00:00",
"pullRequest": null,
"family": null,
"specialist": null,
"contentClassification": "untrusted-knowledge",
"mayContainInstructions": true,
"executable": false
}
]
}
findingText is authored commit text, and untrusted. A finding usually
reads as an imperative, because it describes what should change — which is
exactly the shape a client must not let an agent act on. Every row carries the
safety triple for that reason, and a client renders findings under it the same
way it renders a knowledge body.
Every optional argument is a filter, and each is exact:
| Argument | Selects |
|---|---|
reviewer |
one of code-review, security, adversarial |
severity |
one of CRITICAL, HIGH, MEDIUM, LOW |
commitSha |
a full 40- or 64-character sha, never a short one |
q |
a literal substring of findingText, ASCII case folded |
limit |
at most 100, default 20 |
pullRequest |
a positive number no larger than the widest value its signed 64-bit column can hold — and refused in this build whatever the value, see below |
family, specialist |
refused in this build — see below |
Findings come back newest first: ordered by committedAt descending, then
by commitSha and position ascending, so two findings committed in the same
second have a defined order and a page boundary is stable across calls.
pullRequest, family and specialist are still declared arguments — they
appear in tools/list — and are refused by the server with one constant
message. theurian findings build derives none
of the three, so every stored row carries null for them and a filter on one
would match nothing at all — an empty answer that reads as "nothing was
recorded" rather than "this filter does not work yet". They remain published
fields on every row, because a key that appears only when it has a value
cannot be told apart from a server that predates the key.
pullRequest carries a range as well as that refusal, and the range is checked
first, so the two refusals stay distinguishable: a number outside the column's
range is refused naming the range, and only a number the column could hold
reaches the build-constant refusal above. The ceiling is not a policy choice —
a PR number past it cannot be stored, so it could never match a row — and it is
checked here rather than at the database, where it arrived as a crash instead of
an answer.
Every string filter — reviewer, severity, family, specialist,
commitSha and q — is bounded at 200 characters. A value inside that bound is
quoted back in a refusal, because a typo is what the refusal exists to make
visible; a value past it is reported by its length and never echoed. A number
past the range its column can hold is refused too, and a number with more
decimal digits than the largest storable pullRequest has is described by its
digit count rather than quoted — so every number a caller could plausibly have
meant is still quoted back, and an absurd one is never reflected.
Every string filter also refuses two byte-level shapes rather than repairing
them. A NUL (U+0000) is refused because SQLite's pattern matcher stops
reading at it: a q of "log\u0000zzz" would silently become a search for
log, and a q of "\u0000" alone would match every row. A lone
surrogate — a code point in U+D800–U+DFFF with no pair, which some JSON
decoders will hand a server — is refused because UTF-8 cannot encode it, so it
can be neither compared against a stored value nor carried back in the response.
Neither can appear in a git commit-message line, so no stored value contains one
and no legitimate filter needs one. knowledge.search folds an unpaired
surrogate instead of refusing it; the divergence is deliberate, and the reason is
the same one that makes limit a refusal here and a clamp there — this tool
answers a filtered question, where quietly searching for a value the caller did
not send returns count: 0 about it.
A value outside a bound or a vocabulary is refused naming the bound, not
clamped and not treated as "no filter": a truncated page reads as the whole
answer, a short sha would match nothing and read as "no findings on that
commit", and an unrecognised reviewer treated as no filter would return
everything. count sizes the returned array and is never a total before
limit; truncated is true when at least one servable finding matching
the filter existed past limit, which is how a full page is told apart from the
whole answer. It is one bit about the page's boundary, not a count: the server
reads one row past limit, through the same accepted-findings statement every
served row comes through, and discards it.
One bound on this surface clamps instead of refusing, and it is the only
value whose size the caller does not control. A served findingText
is cut at 2,000 characters and marked with a trailing ..., so a cut value
cannot be read as a whole one. It is the same number knowledge.search bounds a
query at — refused at the wire since SEC-12, and clamped below the surface as a
backstop — derived from that constant rather than chosen again: one bound governs
the longest string this daemon will search for and the longest finding it will
hand back. It fires on nothing a reviewer writes — a finding is one
trailer line, and the longest in this repository's own history was 193
characters when the bound was chosen (measured 2026-09-02) — but findingText
is byte-preserved from a commit message, and a commit message line has no length
limit, so without the cut one planted trailer sets the size of every response
that matches it. Refusing there would let that one line deny the tool to every
caller, and the caller refused would not be the one who wrote the row; every
bound a caller can provoke still refuses.
count: 0 means the filter matched nothing. A project whose store has not been
built here — or whose store is stale or damaged — is refused, with one
constant message naming theurian findings build, so "never built" can never be
read as "no findings".
"Built here" is the load-bearing word, and it is a fourth refusal arm rather than
a restatement of the first. The store is derived and git-ignored, so a repository
can ship one anyway — git add -f puts it past the ignore, and a ZIP or tarball
carries it with no tracking metadata at all — and nothing about such a file is
malformed: correct schema, current stamp, rows naming commits that never existed.
So presence on disk is not evidence, and a store that arrived with a clone is
refused until theurian findings build runs locally, however well-formed it is
(ADR-0004, SEC-7, threat-model
T-19). The refusal is the same constant an absent store gets, deliberately:
naming this arm would tell whoever planted the store that the plant was detected,
and the cure is the same local rebuild either way.
A store path that does not resolve inside the project root is answered apart
from that constant. .theurian/state/theurian-findings-local.sqlite delivered
as a symbolic link out of the working tree — git add -f puts a link past the
ignore as readily as a file — is refused with PATH_ESCAPE_REFUSAL, carrying the
cure for what escaped: remove it, run theurian migrate apply, and rebuild the
derived artifacts. It is answered apart because the rebuild
FINDINGS_UNAVAILABLE_REFUSAL names cannot clear it — theurian findings build
resolves the same leaf through the same helper and meets the same refusal before
it reads git — so one message for both would send a caller to a command that
returns them to where they started. PATH_ESCAPE_REFUSAL interpolates nothing
and so does not say which path escaped; the cure beside it names the derived
subdirectory, drawn from Theurian's own fixed vocabulary, and both are
project-relative. The containment behind it is anchored to the project root,
where review.search's equivalent check is anchored to .theurian/state/ —
which is why the two tools answer an escaping store file with different text.
review.search
Three keys, all always present. The contract is
schemas/mcp/review-search-response.schema.json.
{
"count": 1,
"truncated": false,
"records": [
{
"recordPath": "sha256-9f2c…/review-thread/PRRT_kwDO_test_node_0001.json",
"recordKey": "PRRT_kwDO_test_node_0001",
"kind": "review-thread",
"provider": "github",
"repository": "theurian/theurian",
"pullRequest": 569,
"threadState": "resolved",
"filePath": "packages/theurian-core/src/theurian/security/paths.py",
"sourceUri": "https://github.com/theurian/theurian/pull/569#discussion_r1",
"authorExternalId": "MDQ6VXNlcjE=",
"authorDisplayName": "github-advanced-security",
"excerpt": "This write target is not checked for a symbolic link.",
"excerptChannel": "comment",
"lastSeenRunId": "01K4YQ7N2B3C4D5E6F7G8H9JKM",
"lastSeenAt": "2026-09-05T09:35:33.000000+00:00",
"contentClassification": "untrusted-knowledge",
"mayContainInstructions": true,
"executable": false
}
]
}
Every optional argument is a filter, and all of them are ANDed:
| Argument | Selects |
|---|---|
repository |
one repository, owner/name, exact and case-sensitive |
pullRequest |
one pull-request number, at least 1 and at most MAX_PULL_REQUEST — the largest value its signed 64-bit column can hold |
threadState |
one of open, resolved, outdated, dismissed |
author |
one authorExternalId, exact |
filePath |
one anchor path, exact |
q |
a literal substring of the record's stored text, ASCII case folded |
limit |
at most MAX_REVIEW_SEARCH_LIMIT (50), DEFAULT_REVIEW_SEARCH_LIMIT (20) by default |
projectId is required, as it is for every project-scoped tool: many agents
share one daemon, so an implicit default would resolve one agent's query against
another's project
(ADR-0002).
Records come back in a total, deterministic order the store owns —
repository, then pull request, then kind, then the record's own path. No key in
that order is computed from the query, so limit truncates a defined sequence
and a page boundary is stable across calls.
Nothing on this path is ranked. q is a substring test, not a query
language: *, OR, NEAR and " are ordinary characters, and % and _ are
escaped before the pattern is bound. There is no score, no term weight and no
collection statistic, which is what keeps
ADR-0030 decision 6 clear of
the T-17a constraint a ranked surface inherits: a ranked surface prices its
results over build-time statistics, and no such statistic exists here for a
withheld record to move. What keeps a withheld record out is that it is never
written — no row in any table — so nothing here can tell "withheld" from "never
existed".
Four filters are exact and case-sensitive; only q folds. repository,
filePath, author and threadState are compared byte for byte under SQLite's
default collation, while q folds the 26 ASCII letters and nothing else. The
asymmetry is worth stating because ingestion is case-insensitive about a
repository name — the adapter checks GitHub's answer against the allowlist entry
case-folded, as GitHub itself does — so a project can hold records under a
spelling the operator never typed. Measured 2026-09-10 against a record stored
as Acme/Order-Service: the stored spelling answers one row,
acme/order-service answers none. Read the spelling off a served record's own
repository field rather than assuming one.
Every row carries the safety triple, because every row carries text somebody
outside this project wrote. The excerpt, the display name and the file path are
all author-controlled (ADR-0030 decision 3's field table), so the row is served
under contentClassification: untrusted-knowledge,
mayContainInstructions: true, executable: false. A review comment routinely
reads as an imperative, because a review asks for a change; that is a
description of a request and never an instruction addressed to the agent reading
it. The file path in particular is served as data and SHALL NOT be joined
into a filesystem path — it arrived over the network from whoever opened the pull
request (SEC-7).
Four bounds, and only one of them clamps. limit bounds the records;
MAX_FILTER_CHARS (400) bounds every string filter; the store's own read cuts
each excerpt at MAX_EXCERPT_CHARS (280) in SQL, so the daemon never
materialises the whole of a planted comment; and
MAX_REVIEW_SEARCH_RESPONSE_CHARS bounds the whole response. Every bound a
caller provokes is a refusal naming the bound, never a silent clamp: a
truncated answer to a filtered question reads as the whole answer. The excerpt
is the one that clamps, because its size is chosen by the corpus rather than by
the caller — refusing there would let one planted comment deny the tool to
everyone — and it is cut and then marked with a trailing ..., so the two
lengths are disjoint and a cut value cannot be read as a whole one.
The response bound is a graded stop at the record boundary, never a cut inside
a value. Records are added until the next one would take the response past the
budget; then the page stops and truncated says so, in the same bit and with the
same meaning a full page uses. A record that alone exceeds the budget is served
whole and alone when it is the page's first — a caller whose budget is
smaller than one record is better served by one over-long answer it can truncate
than by an empty one it cannot act on — and that exemption is positional, not a
property of the record: a later over-budget record is simply not served. Every
value served is therefore exactly the stored one, and what varies is how many.
That budget counts content characters of the shaped records, not wire bytes.
JSON escaping costs up to six wire characters for one counted (CJK does not
escape; a control character does), and the SDK sends the payload twice — a
content text block and structured_content — so one response's JSON crosses
the wire two times over. Measured 2026-09-11 over a full page of 50 records:
2.15× the budget figure for long unescaped values, 2.95× for short ones, and
11.36× where every string is control characters. Size a transport limit at
roughly twelve times the budget, never at the budget itself.
count sizes the returned array and is never a total before limit.
truncated is one bit about this response's own boundary: the server reads one
record past limit through the same read every served row comes from, discards
it, and reports whether this response carries fewer records than that read
returned. A total matching count was considered and rejected in its
favour — it would be a number computed over records the caller did not receive.
Two refusal envelopes answer the request itself, and neither carries anything
read out of the store. A caller whose request is outside a bound or a vocabulary
is refused naming the bound, and a value inside the bound may be quoted back
while one past it is reported by its length alone — no refusal here interpolates
a caller's number at all, so there is no arm that can fail while rendering one.
A caller arriving when the daemon is already answering
MAX_CONCURRENT_SEARCHES (4) of these, after waiting
ADMISSION_WAIT_SECONDS (1.0 s) for a permit, gets
REVIEW_SEARCH_CAPACITY_REFUSAL — its own gate and its own message, because a
caller refused here has not been refused by knowledge.search's cap or
review.findings'.
A store that cannot be served from is a refusal, never an empty response, and which text a caller gets turns on whether the fault is in the store or in the path to it.
REVIEW_SEARCH_UNAVAILABLE_REFUSAL — one constant message naming theurian
review build — answers every fault in the store itself: it does not exist, it
carries no build stamp, it was built by a superseded schema or from a superseded
evidence format, it cannot be read, a value in a row it returned is damaged, or
this installation did not build it. The message names none of them.
Distinguishing the arms would publish which one fired, and the provenance arm is
where that costs something: telling "this store is not yours" apart from "there
is no store" tells whoever planted it that the plant was detected. count: 0
therefore means the filter matched nothing, never "nothing has been built here".
A store path that does not resolve inside .theurian/state/ is answered apart
from that constant, because the rebuild the constant names cannot clear it:
theurian review build resolves the store through the same helper before it
reads an evidence file, so it meets the same refusal first. That refusal names
the store's own filename and the project-relative .theurian/state/ and nothing
else, and it travels with a cure that removes that file and then rebuilds — the
rm is the step the rebuild cannot perform for itself, and removing the file
costs only the projection the rebuild recreates from .theurian/review/.
.theurian/state itself resolving outside the project root is refused earlier,
while the project is being resolved, and reaches every project-scoped tool — not
this one alone — as PATH_ESCAPE_REFUSAL beside the cure for what escaped.
That read also needs write access to .theurian/state/, which is not
obvious from a tool that only reads: the store is a WAL database, so SQLite
creates its -wal and -shm companions beside it on the first serving read even
under mode=ro. Measured 2026-09-10 with the directory at 0o500 and the
companions absent, the read fails with attempt to write a readonly database and
reaches the caller as REVIEW_SEARCH_UNAVAILABLE_REFUSAL — whose remedy will not
fix a directory mode. An operator meeting it on a store they know they built
should check the mode before rebuilding.
Where a record came from is not something this tool can vouch for, and the
response does not pretend otherwise. theurian review ingest lands evidence from
public allowlisted repositories; a clone lands it too, because .theurian/review/
is source rather than derived state and is deliberately not git-ignored. The
provenance check is on the store — did this installation build it — and never
on who wrote the records; the read inspects a file's shape and its derived path,
not its authorship. So recordKey, sourceUri, authorExternalId and
lastSeenRunId are the provider's own on a record this installation ingested and
are whatever the file names on one that arrived with the repository (threat model
T-24, an accepted residual). Every row rides under
the triple either way, which is what makes that residual acceptable rather than
merely recorded.
review.generateKnowledgeCandidate
The write-intent tool on this surface. It takes one ingested review thread —
named by the repository and recordKey a review.search record publishes —
plus a generalization the caller authored, and lands a proposal a human reviews
and merges (ADR-0033).
Theurian does not author the generalization. The caller supplies the title,
the body, the kind and the category; Theurian verifies the promotion gate
and packages the result. What Theurian computes is the gate recomputation from
the stored record, the verification of fixCommit against the local git
repository, the candidate with its trustLevel: inferred, and the proposal
directory. Whether the generalization is a fair reading of the thread is the
human reviewer's (FR-V4), and nothing here summarizes, ranks or rewrites the
thread. The name is kept because a tool name is a wire contract; this paragraph
is what it is kept at the cost of.
No promotion-gate signal is a field on the call. Five are recomputed from the
stored record, fixCommit is a value Theurian verifies rather than one it
believes, and generalizable is satisfied by offering a generalization at all. A
gate the caller fills is a gate the caller decides, so none of the seven is
spellable in the input. The same reason keeps trustLevel, sensitivity,
contentType and local off it: the first two are the candidate's, the third is
text/markdown because a generalization is prose, and a --local proposal would
sit where the human review cannot reach it (ADR-0013 point 7).
category is the eleven-member ReviewCommentCategory vocabulary, closed in the
published input schema rather than in the handler, so a wrong value is refused at
the wire with the key path that broke. kind stays a handler refusal whose
message names the valid kinds.
The result is the proposal payload knowledge.proposeChange returns — one shape
for every surface that drafts a proposal:
{
"proposalId": "01K2...",
"proposalDirectory": ".theurian/proposals/01K2...",
"migrationId": "01K2...",
"migrationFile": "01K2...-acquire-locks-after-reads.yaml",
"revisionId": "01K2...",
"expectedRevision": null,
"bodyFile": ".theurian/proposals/01K2.../knowledge/reliability/retry-lock-order.md",
"evidenceFile": ".theurian/proposals/01K2.../evidence.json",
"contentFile": "../knowledge/reliability/retry-lock-order.md",
"contentSha256": "sha256:...",
"bodyDestination": ".theurian/knowledge/reliability/retry-lock-order.md",
"nextSteps": ["..."]
}
Refusals. A record key this installation's built review store does not answer
for, a stored record whose kind is not the one its key promised, and — once
private-repository ingestion creates the class — a withheld record are one
refusal: what separates them is material the caller was not granted. That
commit does not exist here and that commit touched nothing this thread names
are one refusal too, for the same reason one step over: the difference is a fact
about the repository's contents rather than about the request. An unmet gate
names the signals that are unmet and why the stored record makes each one so, and
an unknown CI outcome is told apart from a failed one — go and get a CI result
and this thread is not a candidate are different instructions. A thread stored
with no file anchor is refused in its own words: its fix-commit signal cannot be
verified in v1, and filePath is already published on every review.search
record, so naming the property discloses nothing.
Specification
Specification-specific MCP tools are planned, not shipped. Specification content can be stored and searched as knowledge today, but none of these callable tools exist in the current server.
| Tool | Status | Purpose |
|---|---|---|
spec.search |
Planned | Search specifications |
spec.get |
Planned | Fetch a spec with its structured fields intact |
spec.getDependencies |
Planned | What this spec depends on |
spec.getImplementationStatus |
Planned | Implemented, partial, or not started |
spec.getCoverage |
Planned | Which declared outcomes have verifying tests |
spec.findContradictions |
Planned | Specs that disagree |
spec.findStaleImplementations |
Planned | Code referencing superseded specs |
In the planned specification surface, spec.get returns the native structure,
not a prose rendering. getCoverage depends on that: coverage means "which of
these declared outcomes has a test", and the outcomes must still exist as data
(ADR-0010).
Traceability
Traceability graph queries are planned, not shipped. The current server reports
traceability: false, and none of these tools are callable today.
| Tool | Status | Purpose |
|---|---|---|
trace.get |
Planned | Edges touching a node |
trace.findImplementations |
Planned | What implements a spec |
trace.findTests |
Planned | What verifies a spec |
trace.findUnimplementedSpecs |
Planned | Specified, not built |
trace.findUnverifiedSpecs |
Planned | Built, not tested |
trace.findCodeWithoutSpec |
Planned | Maintained, never specified |
Project and system
| Tool | Status | Purpose | Project-scoped |
|---|---|---|---|
project.list |
Shipped | Registered projects, and the ids nothing can serve | no |
project.status |
Planned | Migration, index, and commit state | yes |
system.health |
HTTP route, not an MCP tool | Liveness and version | no |
system.capabilities |
Shipped | What this build supports | no |
system.indexStatus |
Planned | Active build, staleness, builds in progress | yes |
system.capabilities exists so a client can degrade per feature rather than
all-or-nothing. Version gating is coarse; if only summarization is unconfigured,
everything else should still work.
sensitivityEnforcement: true is the flag with a consequence for how a client
reads an empty answer. This build enforces the disclosure axis against a
ceiling the operator declares, so no results can mean "withheld", not only
"nothing matched" — a client should not tell a user that a project holds no such
knowledge. The flag reports that the axis is enforced and never which ceiling
this deployment declares: that word would tell a caller which levels it is not
being shown, and this tool resolves no project and passes no authorization gate.
An operator reads the ceiling from the file they wrote it into
(ADR-0025).
The response's fields serve different roles, not one uniform contract.
capabilities is what a client degrades against, one feature at a time.
version and protocolVersion re-publish the same two process constants
theurian compat check reads directly when it resolves CP-6
(docs/architecture/requirements-analysis.md, "Claude Code plugin
requirements") — the gate itself never reads this response, so a client does
not compare these two fields to decide compatibility; that comparison already
happened before either surface answered. If the two ever disagree, a client
that already passed the gate sees a different build reported here than the
one it was checked against. For a client that only calls MCP tools, this is
still the sole place protocolVersion is readable at all: liveness is served
at the /health HTTP route, outside the MCP tool surface, with no
callable-tool equivalent in this build. schemaVersion reports the canonical
store's schema version. note is prose, not a field a client parses.
project.list
Four keys, all four always present. The contract is
schemas/mcp/project-list-response.schema.json.
{
"count": 1,
"projects": [{ "projectId": "demo", "rootPath": "/home/dev/demo" }],
"unreadable": ["api"],
"remedy": "Remove them with `theurian project unregister <id>`, then register each project again from its repository. …"
}
| Key | Meaning |
|---|---|
count |
The length of projects, and nothing else — how many projects can be queried |
projects |
Every registration the daemon could read, sorted by id. Two fields per entry, not the whole registry record |
unreadable |
Ids present in the registry whose entries name no root path, sorted. Every other tool refuses these ids; this is where a caller finds out why |
remedy |
What to do about unreadable, or null when it is empty |
unreadable and remedy are required, empty array and null included.
Emitting a key only when it applies makes "nothing is unreadable"
indistinguishable from "this daemon predates the field", and a client that has to
branch on key presence eventually forgets to.
projects and unreadable are not a partition of one snapshot. They come
from two independent reads of the registry file, so an id can appear in both or
in neither if a registration lands between them. Do not compute the size of the
registry by adding the two, and do not read membership of one as absence from the
other.
projectId carries no pattern. Ids Theurian creates are lowercase kebab-case,
but this value is a key of a hand-editable file and nothing validates keys on
read, so an entry keyed Not An Id with a valid rootPath loads and is
published verbatim. A pattern here would make the schema reject output the
product really produces.
Errors
{
"error": {
"code": "REVISION_CONFLICT",
"message": "Revision conflict on architecture.auth-policy",
"details": { "itemId": "...", "expected": "01K1ABC...", "actual": "01K1DEF..." }
}
}
| Code | Meaning |
|---|---|
PROJECT_NOT_REGISTERED |
Unknown projectId |
NOT_AUTHORIZED |
Not permitted for this project |
SNAPSHOT_NOT_FOUND |
The pinned state no longer exists |
INDEX_BUILDING |
No complete index yet — first build only |
REVISION_CONFLICT |
expectedRevision did not match |
MIGRATION_CHECKSUM_MISMATCH |
An applied migration was edited |
INVALID_INPUT |
Failed schema validation |
Errors carry structured details because a message alone forces the caller back
into the code to find out what happened.
knowledge.search publishes one refusal this table does not cover: under
sustained concurrent load, a caller is refused when MAX_CONCURRENT_SEARCHES
(4) calls already hold permits and no permit frees within
ADMISSION_WAIT_SECONDS (1.0 s) — the daemon then refuses with a constant,
retryable ToolError naming the cap, rather than queueing the caller
without bound.
It carries no code from the table above and no details; today the refusal
is distinguishable only by its message text, not by a machine-readable field.
A coded, retryAfter-carrying envelope for it is tracked in
#419.
Safety contract
Every knowledge-bearing result carries the same three fields, always:
{
"contentClassification": "untrusted-knowledge",
"mayContainInstructions": true,
"executable": false
}
executable is const: false in the schema, and a real tool response carrying
executable: true is rejected by it (tests/integration/test_wire_contract.py).
This used to add "and cannot be set true in the domain type": the type that
refuses it, domain.retrieval.SafetyMetadata, is not on the path that produces
this value. See the round-eight correction to T-3 in
the threat model.
Theurian labels; it does not enforce. A calling agent must treat retrieved content as data. An agent that follows instructions found inside a document will be influenced by a document that contains instructions, and no MCP server can prevent that from the server side. This is stated in SECURITY.md rather than buried here.
Changing this contract
Additive changes (a new optional field, a new tool) are MINOR and do not bump
protocolVersion. Removing a field, tightening a type, adding a required field,
or renaming a tool is breaking and bumps it. See
plugin-core-compatibility.md.
knowledge.search's admission refusal (see Errors, above) is a behaviour of
the shipped surface as of this change. It is a client-visible behaviour
change — a call that once queued can now be refused (the CHANGELOG records it
under Changed, not Added) — but it alters no schema and no message shape, so
it does not bump protocolVersion.
protocolVersion is still theurian/v1 after Milestone 5 and #206, which
between them made four breaking changes to this contract. That is a
decision, recorded here because the alternative reading is that somebody
forgot. The rule above governs changes from a released protocol, and
Milestone 5's three qualify outright: no published version of Core has ever
lacked them, so no client was ever pinned to a v1 that lacked them, and
bumping would publish a theurian/v2 whose v1 never shipped. Milestone 5's
breaking set is the content of v1, not a departure from it. milestone's
removal is the fourth, and it does not qualify the same way — its own ground
is below.
The four, so that "breaking but unbumped" is checkable rather than asserted:
the knowledge.search response reshape, the removal of withheldSuperseded,
and the two required fields project.list gained (all Milestone 5), and the
removal of system.capabilities.milestone (#206). Each is named as BREAKING
in the changelog, which is what protects an integrator. A breaking change bumps
unless this section records an exemption for it on grounds specific to that
change; every exemption below is granted once, and none of them widens to cover
the next one.
milestone's exemption rests on different ground. Measured across
core-v0.1.0.dev0 through core-v0.1.0.dev4, the field shipped in every
released tag, all under theurian/v1 — unlike Milestone 5's three, which
never shipped under a released v1 at all before the milestone that changed
them, so a client could, in principle, have been built against
milestone's presence. The exemption is granted anyway, on grounds specific
to this one field: it was never defined in this document or in any schema
under schemas/mcp/, it has zero consumers — search-verified across this
repository, plugins included — and the project is pre-1.0 on a dev line
with no known external integration to break. Publishing theurian/v2 over a
field nothing reads would trip CP-6's compatibility gate
(docs/architecture/requirements-analysis.md, "Claude Code plugin
requirements") for a change no known integrator consumes. This exemption is
scoped to milestone alone: it says nothing about version or
protocolVersion, which re-publish the same constants the gate itself reads
directly, never through this response (see the system.capabilities
paragraph under "Project and system" above).
theurian propose accept's exit code for an already-accepted proposal is the
fifth, and is exempted on the same narrow grounds
(#254). Re-accepting an
accepted proposal exited 1 and now exits 4, which the compatibility table's
"changing an exit code's meaning" row makes breaking. The command shipped in
core-v0.1.0.dev7, so unlike Milestone 5's three this did ship under a
released theurian/v1. The exemption is granted on the same three facts
milestone's rests on: the code has zero consumers — search-verified across
this repository, plugins included, where the only exit-code branch in any
plugin script is session-start.sh on compat check's exit 3
(THEURIAN_EXIT_INCOMPATIBLE), and /theurian:propose reads --json; the
project is pre-1.0 on a dev line with no known external integration; and the
change moves a case toward the meaning this document already published for 4
rather than away from it, since the table always documented 4 for "that
migration is already in place". Scoped to this one code on this one command:
it says nothing about compat check's 0/2/3, which a plugin script does branch
on, or about migrate apply's 4.
SEC-12's three caller-visible refusals are the sixth, seventh and eighth
(ADR-0031).
Publishing an input schema per tool is itself additive — nothing was removed, and
no field became required that the tools did not already require — but each of the
three changes tabulated under Three behaviour changes a caller can
observe turns a call that was
served into a call that is refused, and served-to-refused is the shape the rule
at the top of this section calls breaking. protocolVersion stays theurian/v1,
which takes the breaking-but-unbumped series to eight. Two legs are shared by all
three, and each then has a ground of its own.
The shared leg, one: the consumer census. The population is every place in
this repository that builds an MCP tools/call, excluding Core itself — Core's
own tests are the instrument that pins these refusals, not a consumer of them —
and excluding docs/, which quotes these strings and constructs no call, this
paragraph included. Measured at 03dac2ef on the branch of
#663, and re-runnable as
written:
$ git grep -n '"arguments"' -- . ':!packages/theurian-core' ':!docs'
tests/e2e/test_daemon_single_instance.py:240: "params": {"name": tool, "arguments": arguments},
$ git grep -ln "mcp__\|arguments" -- plugins/claude-code; echo "exit=$?"
exit=1
One construction site, and it is a test helper; no plugin script builds an MCP call at all, because the plugin's scripts shell out to the CLI. That helper's call sites are counted first and then partitioned — into the six written on one line and the two whose argument object wraps — so the listing below is the whole of them, and every one sends keys this document defines:
$ git grep -c '\.call(' -- tests/e2e
tests/e2e/test_daemon_single_instance.py:8
$ git grep -n '\.call("' -- tests/e2e
tests/e2e/test_daemon_single_instance.py:393: result = client.call("knowledge.search", {"projectId": "not-registered", "query": "x"})
tests/e2e/test_daemon_single_instance.py:437: result = client.call("review.findings", {"projectId": "demo"})
tests/e2e/test_daemon_single_instance.py:579: page = client.call("review.findings", {"projectId": "demo", "limit": 1})
tests/e2e/test_daemon_single_instance.py:580: whole = client.call("review.findings", {"projectId": "demo"})
tests/e2e/test_daemon_single_instance.py:581: refused = client.call("review.findings", {"projectId": "demo", "limit": 101})
tests/e2e/test_daemon_single_instance.py:651: capabilities = client.call("system.capabilities", {})
$ git grep -nA1 '\.call($' -- tests/e2e
tests/e2e/test_daemon_single_instance.py:281: result = client.call(
tests/e2e/test_daemon_single_instance.py-282- "knowledge.search", {"projectId": running_daemon.project_id, "query": "JWT"}
--
tests/e2e/test_daemon_single_instance.py:636: results = client.call(
tests/e2e/test_daemon_single_instance.py-637- "knowledge.search", {"projectId": running_daemon.project_id, "query": "JWT"}
Four key sets — {projectId, query}, {projectId}, {projectId, limit}, and
the empty one — every key of them defined above, and the longest query in the
list is three characters. The only two places in the repository that build a
string at the 2,000 bound are inside Core, and neither builds a tools/call:
$ git grep -nE '"[a-z]" \* MAX_QUERY_CHARS|MAX_QUERY_CHARS \+' -- . ':!docs'
packages/theurian-core/tests/integration/test_index_store.py:1607: padding = "x" * MAX_QUERY_CHARS
packages/theurian-core/tests/integration/test_review_findings_tool.py:663: "one-past-the-bound": _finding_text_of(MAX_QUERY_CHARS + 1),
The first drives search_lexical below this surface, where the clamp is still
the backstop; the second sizes a findingText, which clamps by design.
The shared leg, two: the pre-1.0 versioning policy. The Core changelog
records it in its own header — pre-1.0, a MINOR bump may change the protocol;
post-1.0, only a MAJOR may — and that is the leg these three rest on. It is
deliberately not the "no known external integration to break" leg the two
exemptions above use: Core is published on PyPI as theurian, so nobody here can
say what is installed against it. What the policy says is that a pre-1.0 MINOR is
where a protocol change is allowed to land, and that is where these three land.
Sixth: an unknown or extra key on any tool is refused. The drop was the SDK's
argument model, never this contract — no version of this document ever published
that a key it does not name is accepted, so a call carrying one was always
outside the valid surface described here. What this document did call valid is
served unchanged, and that is measured rather than asserted:
test_input_schema_agreement.py holds each published schema's key set and its
handler's parameter set equal, per tool over the registered set, less the three
shared context keys it names; and test_input_validation_wire.py's positive
control answers a valid call through the middleware and again on a server with
the seat lifted off, asserting the two results equal. Scoped to keys no schema
names: it says nothing about a key a schema does name, whose value refusals are
the published bounds, and nothing about the vocabulary and range refusals that
stay inside the tools.
Seventh: project.list and system.capabilities refuse any argument. Same
ground, and a narrower one: neither tool has ever had an argument in this
document or in any schema under schemas/mcp/, so nothing published as accepted
became refused. Sending no argument is unaffected — the census's one
system.capabilities call site passes {}, which is exactly what an empty,
closed properties admits. Scoped to these two tools: it says nothing about
the five project-scoped tools, whose arguments this document does define.
Eighth: a knowledge.search query over 2,000 characters is refused instead
of truncated. This one shares the two legs above but not the
never-published-as-accepted ground the sixth and seventh rest on, and the
difference is worth stating precisely. It is not a type-tightening: no input
schema existed before this change, so there was no published type to tighten —
the input contract was prose. The bound was already published, in two places.
knowledge-search-response.schema.json puts maxLength: 2000 on the query it
echoes back, and this document has carried the same number since before SEC-12,
in the sentence under review.findings that derives findingText's cut from the
bound knowledge.search puts on a query. So what changed is the disposition
of an over-bound query — from clamp to refuse — at a bound the contract already
published, which moves the behaviour toward the meaning the document already
published rather than away from it: the same ground the fifth exemption above
uses in those words. Scoped to query on this one tool: limit and
maxTokens still clamp, findingText still clamps, and no other published bound
changed disposition.
Each of the three is named as BREAKING in the Core changelog by the release that
ships SEC-12, under that release's ### Changed. That entry is what protects an
integrator, and it is not written yet — it lands with the release commit, not
with this section.