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

145 lines
4.7 KiB
TypeScript

/**
* MCP tools for rSocials (campaigns & social threads).
*
* Tools: rsocials_list_campaigns, rsocials_get_campaign,
* rsocials_list_threads, rsocials_create_thread
*/
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import type { SyncServer } from "../local-first/sync-server";
import { socialsDocId } from "../../modules/rsocials/schemas";
import type { SocialsDoc } from "../../modules/rsocials/schemas";
import { resolveAccess, accessDeniedResponse } from "./_auth";
export function registerSocialsTools(server: McpServer, syncServer: SyncServer) {
server.tool(
"rsocials_list_campaigns",
"List social media campaigns in a space",
{
space: z.string().describe("Space slug"),
token: z.string().optional().describe("JWT auth token (required for private/permissioned spaces)"),
},
async ({ space, token }) => {
const access = await resolveAccess(token, space, false);
if (!access.allowed) return accessDeniedResponse(access.reason!);
const doc = syncServer.getDoc<SocialsDoc>(socialsDocId(space));
if (!doc) {
return { content: [{ type: "text", text: JSON.stringify({ error: "No socials data found for this space" }) }] };
}
const campaigns = Object.values(doc.campaigns || {}).map(c => ({
id: c.id,
title: c.title,
description: c.description,
platforms: c.platforms,
postCount: c.posts?.length ?? 0,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
}));
return { content: [{ type: "text", text: JSON.stringify(campaigns, null, 2) }] };
},
);
server.tool(
"rsocials_get_campaign",
"Get full details of a specific campaign including posts",
{
space: z.string().describe("Space slug"),
token: z.string().optional().describe("JWT auth token"),
campaign_id: z.string().describe("Campaign ID"),
},
async ({ space, token, campaign_id }) => {
const access = await resolveAccess(token, space, false);
if (!access.allowed) return accessDeniedResponse(access.reason!);
const doc = syncServer.getDoc<SocialsDoc>(socialsDocId(space));
const campaign = doc?.campaigns?.[campaign_id];
if (!campaign) {
return { content: [{ type: "text", text: JSON.stringify({ error: "Campaign not found" }) }] };
}
return { content: [{ type: "text", text: JSON.stringify(campaign, null, 2) }] };
},
);
server.tool(
"rsocials_list_threads",
"List social threads in a space",
{
space: z.string().describe("Space slug"),
token: z.string().optional().describe("JWT auth token"),
limit: z.number().optional().describe("Max results (default 50)"),
},
async ({ space, token, limit }) => {
const access = await resolveAccess(token, space, false);
if (!access.allowed) return accessDeniedResponse(access.reason!);
const doc = syncServer.getDoc<SocialsDoc>(socialsDocId(space));
if (!doc) {
return { content: [{ type: "text", text: JSON.stringify({ error: "No socials data found" }) }] };
}
const threads = Object.values(doc.threads || {})
.sort((a, b) => b.updatedAt - a.updatedAt)
.slice(0, limit || 50)
.map(t => ({
id: t.id,
name: t.name,
handle: t.handle,
title: t.title,
tweetCount: t.tweets?.length ?? 0,
createdAt: t.createdAt,
updatedAt: t.updatedAt,
}));
return { content: [{ type: "text", text: JSON.stringify(threads, null, 2) }] };
},
);
server.tool(
"rsocials_create_thread",
"Create a new social thread (requires auth token + space membership)",
{
space: z.string().describe("Space slug"),
token: z.string().describe("JWT auth token"),
name: z.string().describe("Author display name"),
handle: z.string().describe("Author handle"),
title: z.string().describe("Thread title"),
tweets: z.array(z.string()).describe("Tweet texts in order"),
},
async ({ space, token, name, handle, title, tweets }) => {
const access = await resolveAccess(token, space, true);
if (!access.allowed) return accessDeniedResponse(access.reason!);
const docId = socialsDocId(space);
const doc = syncServer.getDoc<SocialsDoc>(docId);
if (!doc) {
return { content: [{ type: "text", text: JSON.stringify({ error: "No socials data found" }) }], isError: true };
}
const threadId = `thread-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const now = Date.now();
syncServer.changeDoc<SocialsDoc>(docId, `Create thread ${title}`, (d) => {
if (!d.threads) (d as any).threads = {};
d.threads[threadId] = {
id: threadId,
name,
handle,
title,
tweets,
imageUrl: null,
tweetImages: null,
createdAt: now,
updatedAt: now,
};
});
return { content: [{ type: "text", text: JSON.stringify({ id: threadId, created: true }) }] };
},
);
}