Skip to content

feat(mcp): read-only document tools, on a new v1 REST surface - #376

Merged
QSchlegel merged 1 commit into
preprodfrom
claude/mcp-documents
Aug 21, 2026
Merged

feat(mcp): read-only document tools, on a new v1 REST surface#376
QSchlegel merged 1 commit into
preprodfrom
claude/mcp-documents

Conversation

@QSchlegel

Copy link
Copy Markdown
Collaborator

Adds document_list and document_get under a new documents:read scope, so an agent can answer "what needs signing, and who hasn't signed it" — without being able to act on the answer.

Why this starts with REST, not MCP

Every MCP tool wraps a src/pages/api/v1/* handler through invokeV1, and mcpTools.test.ts asserts on disk that each v1Path exists. There was no document handler — sign-off is tRPC-only — so the REST surface had to come first.

It has standalone value, and routing it through createCaller means the signer-or-owner rule is not re-implemented: there is still exactly one authorization path, the one the UI uses.

The boundary is unchanged

The registry's stated boundary is that nothing reachable over MCP can sign, spend or broadcast. Both new tools are readOnlyHint, so the existing assertion:

const writable = MCP_TOOLS.filter((t) => !t.annotations.readOnlyHint);
expect(writable.map((t) => t.name)).toEqual(["ballot_upsert", "ballot_publish_rationale"]);

still passes unchanged — I extended the order pin only, not the writable allowlist. Nothing here can create, edit, approve or sign a document: approval is a CIP-8 signature from a named wallet signer against a frozen snapshot, which is not reachable from this surface by construction.

Bot keys are excluded twice over. mcpScopesForBot is an explicit allowlist that does not map documents:read, so the tools never appear for a bot at all; and both handlers reject bot JWTs regardless. Sign-off is a human accountability record — every approval is a signature from a named person — and an automated identity has no standing in it.

Responses are a projection, not the rows

summariseDocument is an allowlist, for two reasons:

  • Size. A version can carry up to 512KB of inline base64. That is pure waste as model context and never what a caller asking about signatures wants.
  • Restraint. This output becomes model context, so a new column on DocumentVersion must not be able to start flowing to a model silently.

The outcome field is recomputed from the frozen snapshot via evaluateThreshold rather than read off the row, so a caller sees the same rule the server enforces instead of a denormalised copy.

Titles, descriptions and comments are included — they are the point of the feature — and they are user-authored strings, which is precisely why nothing on this surface can write or sign.

Scope grant

Human wallet JWTs receive documents:read automatically, matching the existing rule that a wallet JWT grants what the signed-in user can already do through the UI. CHALLENGE_SCOPES derives from MCP_SCOPES, so discovery picks it up with no further change.

tsc --noEmit clean; 1087 tests pass; next build exit 0.

🤖 Generated with Claude Code

Adds `document_list` and `document_get` under a new `documents:read`
scope, so an agent can answer "what needs signing, and who hasn't signed
it" without being able to act on the answer.

WHY THIS STARTS WITH REST
Every MCP tool wraps a `src/pages/api/v1/*` handler through `invokeV1`,
and mcpTools.test.ts asserts on disk that each `v1Path` exists. There was
no document handler — sign-off is tRPC-only — so the REST surface comes
first. It has standalone value, and routing it through `createCaller`
means the signer-or-owner rule is not re-implemented: there is still one
authorization path, the one the UI uses.

THE BOUNDARY IS UNCHANGED
Both tools are readOnlyHint, so the existing "exposes no tool that can
sign, spend or broadcast" assertion still lists exactly the two ballot
write tools. Nothing here can create, edit, approve or sign a document —
approval is a CIP-8 signature from a named wallet signer against a frozen
snapshot, which is not reachable from this surface by construction.

Bot keys are excluded twice over: `mcpScopesForBot` is an explicit
allowlist that does not map `documents:read`, so the tools never appear
for a bot, and both handlers reject bot JWTs anyway. Sign-off is a human
accountability record and an automated identity has no standing in it.

RESPONSES ARE A PROJECTION, NOT THE ROWS
`summariseDocument` is an allowlist. A version can carry up to 512KB of
inline base64, which is pure waste as model context and never what a
caller asking about signatures wants — and because this output becomes
model context, a new column on DocumentVersion must not be able to start
flowing to a model silently. The outcome field is recomputed from the
frozen snapshot with `evaluateThreshold`, so a caller sees the same rule
the server enforces rather than a denormalised copy.

Human wallet JWTs receive the new scope automatically, matching the
existing rule that a wallet JWT grants what the signed-in user can
already do in the UI.

1087 tests pass; tsc clean; next build exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
multisig Ready Ready Preview Aug 21, 2026 6:09pm

Request Review

@QSchlegel
QSchlegel merged commit 32c19c7 into preprod Aug 21, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant