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