/** * MCP tools for rVote (proposals & pairwise ranking). * * Tools: rvote_list_proposals, rvote_get_proposal, rvote_get_results */ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import type { SyncServer } from "../local-first/sync-server"; import type { ProposalDoc } from "../../modules/rvote/schemas"; import { resolveAccess, accessDeniedResponse } from "./_auth"; const PROPOSAL_PREFIX = ":vote:proposals:"; function findProposalDocIds(syncServer: SyncServer, space: string): string[] { const prefix = `${space}${PROPOSAL_PREFIX}`; return syncServer.getDocIds().filter(id => id.startsWith(prefix)); } export function registerVoteTools(server: McpServer, syncServer: SyncServer) { server.tool( "rvote_list_proposals", "List governance proposals in a space with Elo rankings and vote tallies", { space: z.string().describe("Space slug"), token: z.string().optional().describe("JWT auth token"), status: z.string().optional().describe("Filter by status"), limit: z.number().optional().describe("Max results (default 50)"), }, async ({ space, token, status, limit }) => { const access = await resolveAccess(token, space, false); if (!access.allowed) return accessDeniedResponse(access.reason!); const docIds = findProposalDocIds(syncServer, space); let proposals: any[] = []; for (const docId of docIds) { const doc = syncServer.getDoc(docId); if (!doc?.proposal) continue; const p = doc.proposal; proposals.push({ id: p.id, title: p.title, description: (p.description || "").slice(0, 200), status: p.status, score: p.score, elo: doc.pairwise?.elo ?? null, comparisons: doc.pairwise?.comparisons ?? 0, finalYes: p.finalYes, finalNo: p.finalNo, finalAbstain: p.finalAbstain, voteCount: Object.keys(doc.votes || {}).length, votingEndsAt: p.votingEndsAt, createdAt: p.createdAt, }); } if (status) proposals = proposals.filter(p => p.status === status); proposals.sort((a, b) => (b.elo ?? 0) - (a.elo ?? 0)); proposals = proposals.slice(0, limit || 50); return { content: [{ type: "text", text: JSON.stringify(proposals, null, 2) }] }; }, ); server.tool( "rvote_get_proposal", "Get full details of a specific proposal including votes and pairwise data", { space: z.string().describe("Space slug"), token: z.string().optional().describe("JWT auth token"), proposal_id: z.string().describe("Proposal ID"), }, async ({ space, token, proposal_id }) => { const access = await resolveAccess(token, space, false); if (!access.allowed) return accessDeniedResponse(access.reason!); const docId = `${space}${PROPOSAL_PREFIX}${proposal_id}`; const doc = syncServer.getDoc(docId); if (!doc?.proposal) { return { content: [{ type: "text", text: JSON.stringify({ error: "Proposal not found" }) }] }; } return { content: [{ type: "text", text: JSON.stringify({ proposal: doc.proposal, pairwise: doc.pairwise, voteCount: Object.keys(doc.votes || {}).length, finalVoteCount: Object.keys(doc.finalVotes || {}).length, }, null, 2), }], }; }, ); server.tool( "rvote_get_results", "Get vote results summary: Elo rankings across all proposals", { space: z.string().describe("Space slug"), token: z.string().optional().describe("JWT auth token"), }, async ({ space, token }) => { const access = await resolveAccess(token, space, false); if (!access.allowed) return accessDeniedResponse(access.reason!); const docIds = findProposalDocIds(syncServer, space); const rankings = []; for (const docId of docIds) { const doc = syncServer.getDoc(docId); if (!doc?.proposal) continue; rankings.push({ id: doc.proposal.id, title: doc.proposal.title, status: doc.proposal.status, elo: doc.pairwise?.elo ?? 1500, comparisons: doc.pairwise?.comparisons ?? 0, wins: doc.pairwise?.wins ?? 0, finalYes: doc.proposal.finalYes, finalNo: doc.proposal.finalNo, finalAbstain: doc.proposal.finalAbstain, }); } rankings.sort((a, b) => b.elo - a.elo); return { content: [{ type: "text", text: JSON.stringify(rankings, null, 2) }] }; }, ); }