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