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

130 lines
4.5 KiB
TypeScript

/**
* 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<ChoicesDoc>(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<ChoicesDoc>(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<ChoicesDoc>(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<string, { label: string; totalScore: number; voteCount: number }> = {};
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),
}],
};
},
);
}