Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/__tests__/mcpTools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ describe("MCP tool registry", () => {
"governance_open_proposals",
"ballot_upsert",
"ballot_publish_rationale",
"document_list",
"document_get",
]);
});

Expand Down
33 changes: 27 additions & 6 deletions src/data/mcp-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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.",
},
];
127 changes: 127 additions & 0 deletions src/lib/documents/summary.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
41 changes: 36 additions & 5 deletions src/lib/mcp/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,14 @@ export type JsonSchema = Record<string, unknown>;
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 = {
Expand All @@ -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: {
Expand Down Expand Up @@ -132,11 +160,13 @@ export const BALLOT_UPSERT_INPUT: JsonSchema = {
proposalId: {
type: "string",
minLength: 1,
description: "Governance proposal id, in <txHash>#<certIndex> form.",
description:
"Governance proposal id, in <txHash>#<certIndex> 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",
Expand Down Expand Up @@ -213,7 +243,8 @@ export const PUBLISH_RATIONALE_INPUT: JsonSchema = {
proposalId: {
type: "string",
minLength: 1,
description: "Governance proposal id (<txHash>#<certIndex>) on that ballot.",
description:
"Governance proposal id (<txHash>#<certIndex>) on that ballot.",
},
summary: {
type: "string",
Expand Down
5 changes: 5 additions & 0 deletions src/lib/mcp/scopes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -28,6 +29,10 @@ export const MCP_SCOPE_DESCRIPTIONS: Record<McpScope, string> = {
// 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 {
Expand Down
Loading
Loading