rspace-online/server/mcp-tools/rvote.ts

133 lines
4.2 KiB
TypeScript

/**
* 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<ProposalDoc>(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<ProposalDoc>(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<ProposalDoc>(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) }] };
},
);
}