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.search
  • knowledge.get
  • knowledge.status
  • knowledge.proposeChange
  • knowledge.generateMigrationDraft
  • project.list
  • review.findings
  • review.generateKnowledgeCandidate
  • review.search
  • system.capabilities

system.capabilities is the runtime boundary for clients. In this build it reports reviewFindings: true and reviewIngestion: truereview.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+D800U+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.