From 6c0ca2f2044220bbb383319489aa42f3fa5ed033 Mon Sep 17 00:00:00 2001 From: QSchlegel Date: Fri, 21 Aug 2026 20:04:10 +0200 Subject: [PATCH] feat(mcp): read-only document tools, on a new v1 REST surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/__tests__/mcpTools.test.ts | 2 + src/data/mcp-tools.ts | 33 ++++++-- src/lib/documents/summary.ts | 127 +++++++++++++++++++++++++++++ src/lib/mcp/schemas.ts | 41 ++++++++-- src/lib/mcp/scopes.ts | 5 ++ src/lib/mcp/tools.ts | 85 +++++++++++++++++-- src/pages/api/v1/documentDetail.ts | 103 +++++++++++++++++++++++ src/pages/api/v1/documents.ts | 102 +++++++++++++++++++++++ 8 files changed, 479 insertions(+), 19 deletions(-) create mode 100644 src/lib/documents/summary.ts create mode 100644 src/pages/api/v1/documentDetail.ts create mode 100644 src/pages/api/v1/documents.ts diff --git a/src/__tests__/mcpTools.test.ts b/src/__tests__/mcpTools.test.ts index a7fe7eee..977bb849 100644 --- a/src/__tests__/mcpTools.test.ts +++ b/src/__tests__/mcpTools.test.ts @@ -34,6 +34,8 @@ describe("MCP tool registry", () => { "governance_open_proposals", "ballot_upsert", "ballot_publish_rationale", + "document_list", + "document_get", ]); }); diff --git a/src/data/mcp-tools.ts b/src/data/mcp-tools.ts index 880f4fed..173997d2 100644 --- a/src/data/mcp-tools.ts +++ b/src/data/mcp-tools.ts @@ -10,7 +10,11 @@ export type McpToolSummary = { name: string; - scope: "wallets:read" | "governance:read" | "ballots:write"; + scope: + | "wallets:read" + | "governance:read" + | "ballots:write" + | "documents:read"; /** One line, phrased for someone deciding whether to connect. */ blurb: string; }; @@ -34,7 +38,8 @@ export const MCP_TOOL_SUMMARIES: McpToolSummary[] = [ { name: "multisig_list_free_utxos", scope: "wallets:read", - blurb: "UTxOs not already locked by a pending transaction — what you can actually spend.", + blurb: + "UTxOs not already locked by a pending transaction — what you can actually spend.", }, { name: "multisig_list_proxies", @@ -49,7 +54,8 @@ export const MCP_TOOL_SUMMARIES: McpToolSummary[] = [ { name: "multisig_lookup_wallet", scope: "wallets:read", - blurb: "Find on-chain multisig registration metadata by participant key hash.", + blurb: + "Find on-chain multisig registration metadata by participant key hash.", }, { name: "governance_list_active_proposals", @@ -69,16 +75,31 @@ export const MCP_TOOL_SUMMARIES: McpToolSummary[] = [ { name: "governance_open_proposals", scope: "governance:read", - blurb: "Active proposals you have not voted on yet — the outstanding decisions.", + blurb: + "Active proposals you have not voted on yet — the outstanding decisions.", }, { name: "ballot_upsert", scope: "ballots:write", - blurb: "Create or update a ballot draft: a choice per proposal, plus rationale text.", + blurb: + "Create or update a ballot draft: a choice per proposal, plus rationale text.", }, { name: "ballot_publish_rationale", scope: "ballots:write", - blurb: "Publish a rationale to IPFS and record its anchor, ready for you to vote.", + blurb: + "Publish a rationale to IPFS and record its anchor, ready for you to vote.", + }, + { + name: "document_list", + scope: "documents:read", + blurb: + "Sign-off documents for a wallet, and who still needs to sign each one.", + }, + { + name: "document_get", + scope: "documents:read", + blurb: + "One document in full: every version, its hash, and who approved it.", }, ]; diff --git a/src/lib/documents/summary.ts b/src/lib/documents/summary.ts new file mode 100644 index 00000000..06a2cfa9 --- /dev/null +++ b/src/lib/documents/summary.ts @@ -0,0 +1,127 @@ +import { evaluateThreshold } from "./payload"; + +/** + * Compact projections of a document for the REST/MCP surface. + * + * Two reasons this exists rather than returning the Prisma rows: + * + * - SIZE. A version may carry up to 512KB of inline base64 (`contentInline`). + * Handing that to a model as tool output is pure waste, and it is never what + * a caller asking "what needs signing?" wants. + * - RESTRAINT. These endpoints back MCP tools, so whatever they return becomes + * model context. The projection is an allowlist: fields are here because a + * caller needs them, so a new column on DocumentVersion cannot silently + * start flowing to a model. + * + * Titles, descriptions and comments ARE included — they are the point of the + * feature and cannot be withheld — but they are user-authored strings, which is + * exactly why nothing on this surface can write or sign. + */ + +type ReviewLike = { + signerAddress: string; + action: string; + signedAt?: Date | string | null; + comment?: string | null; +}; + +type SnapshotLike = { + signersAddresses: string[]; + requiredSigners: number; +} | null; + +type VersionLike = { + id: string; + versionNumber: number; + contentHash: string; + hashAlgorithm: string; + status: string; + fileName?: string | null; + mimeType?: string | null; + fileSize?: number | null; + storageMode: string; + createdBy: string; + createdAt: Date | string; + decidedAt?: Date | string | null; + supersededAt?: Date | string | null; + reviews?: ReviewLike[]; + signerSnapshot?: SnapshotLike; +}; + +type DocumentLike = { + id: string; + walletId: string; + title: string; + description?: string | null; + documentType?: string | null; + status: string; + createdBy: string; + createdAt: Date | string; + updatedAt: Date | string; + versions?: VersionLike[]; +}; + +const iso = (value: Date | string | null | undefined): string | null => + value ? new Date(value).toISOString() : null; + +export function summariseVersion(version: VersionLike) { + const reviews = version.reviews ?? []; + const approvals = reviews.filter((r) => r.action === "approve").length; + const rejections = reviews.filter((r) => r.action === "reject").length; + const snapshot = version.signerSnapshot ?? null; + + const acted = new Set(reviews.map((r) => r.signerAddress)); + const awaiting = (snapshot?.signersAddresses ?? []).filter( + (address) => !acted.has(address), + ); + + return { + versionId: version.id, + versionNumber: version.versionNumber, + status: version.status, + contentHash: version.contentHash, + hashAlgorithm: version.hashAlgorithm, + fileName: version.fileName ?? null, + mimeType: version.mimeType ?? null, + fileSize: version.fileSize ?? null, + storageMode: version.storageMode, + createdBy: version.createdBy, + createdAt: iso(version.createdAt), + decidedAt: iso(version.decidedAt), + supersededAt: iso(version.supersededAt), + approvals, + rejections, + requiredSigners: snapshot?.requiredSigners ?? null, + // Only meaningful once a round has started and a snapshot exists. + awaitingSignatures: snapshot ? awaiting : null, + // Recomputed from the frozen snapshot rather than read off the row, so a + // caller sees the same rule the server enforces. + outcome: snapshot + ? evaluateThreshold({ + approvals, + rejections, + signerCount: snapshot.signersAddresses.length, + requiredSigners: snapshot.requiredSigners, + }) + : null, + }; +} + +export function summariseDocument(document: DocumentLike) { + const versions = (document.versions ?? []).map(summariseVersion); + return { + documentId: document.id, + walletId: document.walletId, + title: document.title, + description: document.description ?? null, + documentType: document.documentType ?? null, + status: document.status, + createdBy: document.createdBy, + createdAt: iso(document.createdAt), + updatedAt: iso(document.updatedAt), + versionCount: versions.length, + /** Highest version number — the one a signer would act on. */ + latestVersion: versions[0] ?? null, + versions, + }; +} diff --git a/src/lib/mcp/schemas.ts b/src/lib/mcp/schemas.ts index bd671318..27054bed 100644 --- a/src/lib/mcp/schemas.ts +++ b/src/lib/mcp/schemas.ts @@ -17,13 +17,14 @@ export type JsonSchema = Record; const walletId = { type: "string", minLength: 1, - description: "Wallet UUID from the multisig database (not a Cardano address).", + description: + "Wallet UUID from the multisig database (not a Cardano address).", } as const; const network = { type: "string", enum: ["0", "1"], - description: "Cardano network: \"0\" = preprod, \"1\" = mainnet.", + description: 'Cardano network: "0" = preprod, "1" = mainnet.', } as const; export const EMPTY_INPUT: JsonSchema = { @@ -39,6 +40,33 @@ export const WALLET_ONLY_INPUT: JsonSchema = { additionalProperties: false, }; +export const DOCUMENT_LIST_INPUT: JsonSchema = { + type: "object", + properties: { + walletId, + includeArchived: { + type: "boolean", + default: false, + description: "Include archived documents. Off by default.", + }, + }, + required: ["walletId"], + additionalProperties: false, +}; + +export const DOCUMENT_GET_INPUT: JsonSchema = { + type: "object", + properties: { + documentId: { + type: "string", + description: + "Document id, as returned by document_list in the documentId field.", + }, + }, + required: ["documentId"], + additionalProperties: false, +}; + export const FREE_UTXOS_INPUT: JsonSchema = { type: "object", properties: { @@ -132,11 +160,13 @@ export const BALLOT_UPSERT_INPUT: JsonSchema = { proposalId: { type: "string", minLength: 1, - description: "Governance proposal id, in # form.", + description: + "Governance proposal id, in # form.", }, proposalTitle: { type: "string", - description: "Human-readable proposal title. Required by the handler.", + description: + "Human-readable proposal title. Required by the handler.", }, choice: { type: "string", @@ -213,7 +243,8 @@ export const PUBLISH_RATIONALE_INPUT: JsonSchema = { proposalId: { type: "string", minLength: 1, - description: "Governance proposal id (#) on that ballot.", + description: + "Governance proposal id (#) on that ballot.", }, summary: { type: "string", diff --git a/src/lib/mcp/scopes.ts b/src/lib/mcp/scopes.ts index c98e931e..36a5c09d 100644 --- a/src/lib/mcp/scopes.ts +++ b/src/lib/mcp/scopes.ts @@ -14,6 +14,7 @@ export const MCP_SCOPES = [ "wallets:read", "governance:read", "ballots:write", + "documents:read", ] as const; export type McpScope = (typeof MCP_SCOPES)[number]; @@ -28,6 +29,10 @@ export const MCP_SCOPE_DESCRIPTIONS: Record = { // public and effectively permanent — "ballot drafts" alone undersells that. "ballots:write": "Create and update governance ballot drafts, and publish rationale documents publicly to IPFS. Cannot vote on-chain.", + // Read-only and says so. Sign-off approvals are CIP-8 signatures from a named + // human signer; nothing reachable through MCP can produce one. + "documents:read": + "Read your wallets' sign-off documents: titles, version history, content hashes and who still needs to sign. Cannot create, edit, approve or sign anything.", }; export function isMcpScope(value: string): value is McpScope { diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index 910a1d45..e60a9559 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -6,6 +6,8 @@ import type { McpScope } from "@/lib/mcp/scopes"; import { ACTIVE_PROPOSALS_INPUT, BALLOT_UPSERT_INPUT, + DOCUMENT_GET_INPUT, + DOCUMENT_LIST_INPUT, OPEN_PROPOSALS_INPUT, PUBLISH_RATIONALE_INPUT, VOTE_HISTORY_INPUT, @@ -57,8 +59,16 @@ export type McpToolDef = { run: (args: Record, ctx: ToolContext) => Promise; }; -const READ_ONLY = { readOnlyHint: true, idempotentHint: true, openWorldHint: false } as const; -const READ_ONLY_CHAIN = { readOnlyHint: true, idempotentHint: true, openWorldHint: true } as const; +const READ_ONLY = { + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, +} as const; +const READ_ONLY_CHAIN = { + readOnlyHint: true, + idempotentHint: true, + openWorldHint: true, +} as const; /** * Lazy handler imports. @@ -83,6 +93,8 @@ const load = { drepInfo: () => import("@/pages/api/v1/drepInfo"), drepVotes: () => import("@/pages/api/governance/drepVotes"), ballotRationaleAnchor: () => import("@/pages/api/v1/ballotRationaleAnchor"), + documents: () => import("@/pages/api/v1/documents"), + documentDetail: () => import("@/pages/api/v1/documentDetail"), }; /** Vote history is two hops: resolve the wallet's DRep, then read its votes. */ @@ -367,12 +379,19 @@ export const MCP_TOOLS: McpToolDef[] = [ const active = await callV1(load.governanceActiveProposals, ctx, { method: "GET", - query: { network, count: String(count), page: "1", order: "desc", details: "false" }, + query: { + network, + count: String(count), + page: "1", + order: "desc", + details: "false", + }, }); if (active.status >= 400) return active; const proposals = - (active.body as { proposals?: { proposalId: string }[] }).proposals ?? []; + (active.body as { proposals?: { proposalId: string }[] }).proposals ?? + []; // A missing DRep or a Koios hiccup must not sink the whole answer — fall // back to "we don't know what was voted" rather than failing the call. @@ -388,10 +407,16 @@ export const MCP_TOOLS: McpToolDef[] = [ const annotated = proposals.map((p) => { const ours = voted.get(p.proposalId); - return { ...p, alreadyVoted: Boolean(ours), ourVote: ours?.vote ?? null }; + return { + ...p, + alreadyVoted: Boolean(ours), + ourVote: ours?.vote ?? null, + }; }); const includeVoted = args.includeVoted === true; - const rows = includeVoted ? annotated : annotated.filter((p) => !p.alreadyVoted); + const rows = includeVoted + ? annotated + : annotated.filter((p) => !p.alreadyVoted); return { status: 200, @@ -467,8 +492,52 @@ export const MCP_TOOLS: McpToolDef[] = [ ...(args.counterargumentDiscussion !== undefined ? { counterargumentDiscussion: args.counterargumentDiscussion } : {}), - ...(args.conclusion !== undefined ? { conclusion: args.conclusion } : {}), - ...(args.references !== undefined ? { references: args.references } : {}), + ...(args.conclusion !== undefined + ? { conclusion: args.conclusion } + : {}), + ...(args.references !== undefined + ? { references: args.references } + : {}), + }, + }), + }, + { + name: "document_list", + title: "List sign-off documents", + description: + "List a wallet's sign-off documents with their version history: content hashes, approval counts, the threshold each round needs, and which signers have not signed yet. Read-only — approving a document requires a signature from a wallet signer and cannot be done through this tool.", + scope: "documents:read", + inputSchema: DOCUMENT_LIST_INPUT, + annotations: READ_ONLY, + v1Path: "documents.ts", + run: async (args, ctx) => + wrapArray( + await callV1(load.documents, ctx, { + method: "GET", + query: { + walletId: String(args.walletId), + address: ctx.caller.subject, + ...(args.includeArchived ? { includeArchived: "true" } : {}), + }, + }), + "documents", + ), + }, + { + name: "document_get", + title: "Get a sign-off document", + description: + "Get one sign-off document by id: every version with its content hash and status, who approved or rejected each one, and the document's audit history. Read-only.", + scope: "documents:read", + inputSchema: DOCUMENT_GET_INPUT, + annotations: READ_ONLY, + v1Path: "documentDetail.ts", + run: async (args, ctx) => + callV1(load.documentDetail, ctx, { + method: "GET", + query: { + documentId: String(args.documentId), + address: ctx.caller.subject, }, }), }, diff --git a/src/pages/api/v1/documentDetail.ts b/src/pages/api/v1/documentDetail.ts new file mode 100644 index 00000000..7247a726 --- /dev/null +++ b/src/pages/api/v1/documentDetail.ts @@ -0,0 +1,103 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { summariseDocument } from "@/lib/documents/summary"; +import { addCorsCacheBustingHeaders, cors } from "@/lib/cors"; +import { getClientIP } from "@/lib/security/rateLimit"; +import { + applyBotRateLimit, + applyRateLimit, +} from "@/lib/security/requestGuards"; +import { isBotJwt, verifyJwt } from "@/lib/verifyJwt"; +import { createCaller } from "@/server/api/root"; +import { db } from "@/server/db"; + +/** + * GET /api/v1/documentDetail?documentId=&address= — one document with its + * version history and audit events. + * + * Same rules as `documents.ts`: authorization goes through + * `caller.document.getById` rather than being re-implemented, the response is a + * projection, and bot keys are excluded. + */ +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + addCorsCacheBustingHeaders(res); + + if (!applyRateLimit(req, res, { keySuffix: "v1/documentDetail" })) return; + + await cors(req, res); + if (req.method === "OPTIONS") return res.status(200).end(); + if (req.method !== "GET") { + return res.status(405).json({ error: "Method Not Allowed" }); + } + + const authHeader = req.headers.authorization; + const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null; + if (!token) { + return res.status(401).json({ + error: + "Unauthorized - Missing or malformed Authorization header (expected: Bearer )", + }); + } + + const payload = verifyJwt(token); + if (!payload) { + return res.status(401).json({ error: "Invalid or expired token" }); + } + + if (isBotJwt(payload) && !applyBotRateLimit(req, res, payload.botId)) return; + + const { documentId, address } = req.query; + if (typeof address !== "string") { + return res.status(400).json({ error: "Invalid address parameter" }); + } + if (payload.address !== address) { + return res.status(403).json({ error: "Address mismatch" }); + } + if (typeof documentId !== "string") { + return res.status(400).json({ error: "Invalid documentId parameter" }); + } + + if (isBotJwt(payload)) { + // See documents.ts: sign-off is a human accountability record. + return res + .status(403) + .json({ error: "Document sign-off is not available to bot keys" }); + } + + try { + const caller = createCaller({ + db, + session: { + user: { id: payload.address }, + expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }, + sessionAddress: payload.address, + sessionWallets: [payload.address], + primaryWallet: payload.address, + ip: getClientIP(req), + }); + + const document = await caller.document.getById({ documentId }); + if (!document) { + return res.status(404).json({ error: "Document not found" }); + } + + return res.status(200).json({ + ...summariseDocument(document), + events: (document.events ?? []).map((event) => ({ + type: event.type, + actorAddress: event.actorAddress, + createdAt: new Date(event.createdAt).toISOString(), + })), + }); + } catch (error) { + console.error("Error in documentDetail handler", { + message: (error as Error)?.message, + stack: (error as Error)?.stack, + }); + return res.status(500).json({ error: "Internal Server Error" }); + } +} diff --git a/src/pages/api/v1/documents.ts b/src/pages/api/v1/documents.ts new file mode 100644 index 00000000..c2dba689 --- /dev/null +++ b/src/pages/api/v1/documents.ts @@ -0,0 +1,102 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { addCorsCacheBustingHeaders, cors } from "@/lib/cors"; +import { getClientIP } from "@/lib/security/rateLimit"; +import { + applyBotRateLimit, + applyRateLimit, +} from "@/lib/security/requestGuards"; +import { isBotJwt, verifyJwt } from "@/lib/verifyJwt"; +import { createCaller } from "@/server/api/root"; +import { db } from "@/server/db"; +import { summariseDocument } from "@/lib/documents/summary"; + +/** + * GET /api/v1/documents?walletId=&address= — sign-off documents for a wallet. + * + * Authorization is not re-implemented here: the request goes through + * `caller.document.listByWallet`, so the same signer-or-owner rule the UI uses + * applies, and there is one place to change it. + * + * The response is a deliberate PROJECTION, not the raw rows — see + * `summariseDocument`. This endpoint backs an MCP tool, so its output reaches a + * model, and the raw rows carry up to 512KB of inline base64 per version. + */ +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + addCorsCacheBustingHeaders(res); + + if (!applyRateLimit(req, res, { keySuffix: "v1/documents" })) return; + + await cors(req, res); + if (req.method === "OPTIONS") return res.status(200).end(); + if (req.method !== "GET") { + return res.status(405).json({ error: "Method Not Allowed" }); + } + + const authHeader = req.headers.authorization; + const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null; + if (!token) { + return res.status(401).json({ + error: + "Unauthorized - Missing or malformed Authorization header (expected: Bearer )", + }); + } + + const payload = verifyJwt(token); + if (!payload) { + return res.status(401).json({ error: "Invalid or expired token" }); + } + + if (isBotJwt(payload)) { + if (!applyBotRateLimit(req, res, payload.botId)) return; + // Bot keys are deliberately excluded. Sign-off is a human accountability + // record: every approval is a CIP-8 signature from a named wallet signer, + // and an automated identity has no standing in it. Reading alone is + // harmless, but it is the first step of a surface that only makes sense for + // people, so it waits for a reason to exist. + return res + .status(403) + .json({ error: "Document sign-off is not available to bot keys" }); + } + + const { walletId, address, includeArchived } = req.query; + if (typeof address !== "string") { + return res.status(400).json({ error: "Invalid address parameter" }); + } + if (payload.address !== address) { + return res.status(403).json({ error: "Address mismatch" }); + } + if (typeof walletId !== "string") { + return res.status(400).json({ error: "Invalid walletId parameter" }); + } + + try { + const caller = createCaller({ + db, + session: { + user: { id: payload.address }, + expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }, + sessionAddress: payload.address, + sessionWallets: [payload.address], + primaryWallet: payload.address, + ip: getClientIP(req), + }); + + const documents = await caller.document.listByWallet({ + walletId, + includeArchived: includeArchived === "true", + }); + + return res.status(200).json(documents.map(summariseDocument)); + } catch (error) { + console.error("Error in documents handler", { + message: (error as Error)?.message, + stack: (error as Error)?.stack, + }); + return res.status(500).json({ error: "Internal Server Error" }); + } +}