133 lines
4.2 KiB
TypeScript
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) }] };
|
|
},
|
|
);
|
|
}
|