/** * MCP tools for rChoices (voting sessions — vote/rank/score). * * Tools: rchoices_list_sessions, rchoices_get_session, rchoices_get_results */ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import type { SyncServer } from "../local-first/sync-server"; import { choicesDocId } from "../../modules/rchoices/schemas"; import type { ChoicesDoc } from "../../modules/rchoices/schemas"; import { resolveAccess, accessDeniedResponse } from "./_auth"; export function registerChoicesTools(server: McpServer, syncServer: SyncServer) { server.tool( "rchoices_list_sessions", "List voting/ranking sessions in a space", { space: z.string().describe("Space slug"), token: z.string().optional().describe("JWT auth token"), type: z.string().optional().describe("Filter by type (vote, rank, score)"), include_closed: z.boolean().optional().describe("Include closed sessions (default false)"), }, async ({ space, token, type, include_closed }) => { const access = await resolveAccess(token, space, false); if (!access.allowed) return accessDeniedResponse(access.reason!); const doc = syncServer.getDoc(choicesDocId(space)); if (!doc) { return { content: [{ type: "text", text: JSON.stringify({ error: "No choices data found" }) }] }; } let sessions = Object.values(doc.sessions || {}); if (!include_closed) sessions = sessions.filter(s => !s.closed); if (type) sessions = sessions.filter(s => s.type === type); const summary = sessions.map(s => ({ id: s.id, title: s.title, type: s.type, mode: s.mode, optionCount: s.options?.length ?? 0, closed: s.closed, createdAt: s.createdAt, })); return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] }; }, ); server.tool( "rchoices_get_session", "Get full details of a voting session including options", { space: z.string().describe("Space slug"), token: z.string().optional().describe("JWT auth token"), session_id: z.string().describe("Session ID"), }, async ({ space, token, session_id }) => { const access = await resolveAccess(token, space, false); if (!access.allowed) return accessDeniedResponse(access.reason!); const doc = syncServer.getDoc(choicesDocId(space)); const session = doc?.sessions?.[session_id]; if (!session) { return { content: [{ type: "text", text: JSON.stringify({ error: "Session not found" }) }] }; } const voteCount = Object.values(doc!.votes || {}) .filter(v => v.choices && Object.keys(v.choices).some(k => session.options?.some(o => o.id === k))) .length; return { content: [{ type: "text", text: JSON.stringify({ ...session, voteCount }, null, 2) }] }; }, ); server.tool( "rchoices_get_results", "Get tallied results for a voting session", { space: z.string().describe("Space slug"), token: z.string().optional().describe("JWT auth token"), session_id: z.string().describe("Session ID"), }, async ({ space, token, session_id }) => { const access = await resolveAccess(token, space, false); if (!access.allowed) return accessDeniedResponse(access.reason!); const doc = syncServer.getDoc(choicesDocId(space)); const session = doc?.sessions?.[session_id]; if (!session) { return { content: [{ type: "text", text: JSON.stringify({ error: "Session not found" }) }] }; } const optionIds = new Set(session.options?.map(o => o.id) || []); const relevantVotes = Object.values(doc!.votes || {}) .filter(v => v.choices && Object.keys(v.choices).some(k => optionIds.has(k))); // Tally const tallies: Record = {}; for (const opt of session.options || []) { tallies[opt.id] = { label: opt.label, totalScore: 0, voteCount: 0 }; } for (const vote of relevantVotes) { for (const [optId, score] of Object.entries(vote.choices || {})) { if (tallies[optId]) { tallies[optId].totalScore += score; tallies[optId].voteCount++; } } } const results = Object.entries(tallies) .map(([id, t]) => ({ id, ...t, avgScore: t.voteCount > 0 ? t.totalScore / t.voteCount : 0 })) .sort((a, b) => b.totalScore - a.totalScore); return { content: [{ type: "text", text: JSON.stringify({ session: { id: session.id, title: session.title, type: session.type, closed: session.closed }, totalVoters: relevantVotes.length, results, }, null, 2), }], }; }, ); }