- competitor-scan skill + run type: web search for local rivals and niche benchmarks, competitor_suggest checks each on Instagram - Suggestions inbox on Competitors page (Track / Dismiss) - Report blocks: leaderboard, size-vs-engagement map, format lift, market's best posts, hashtag gap, Google ratings - Stats: engagement by format, top hashtags; audit adds leaderboard Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
230 lines
18 KiB
JavaScript
230 lines
18 KiB
JavaScript
#!/usr/bin/env node
|
||
// MCP server exposing client social accounts to Claude.
|
||
// Read tools are always on. Publish tools only work when ALLOW_PUBLISH=true in .env AND confirm=true is passed.
|
||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||
import { z } from "zod";
|
||
import fs from "node:fs";
|
||
import path from "node:path";
|
||
import { PROJECT_ROOT } from "./env.js";
|
||
import * as meta from "./meta.js";
|
||
import * as sched from "./schedule.js";
|
||
import * as lib from "./library.js";
|
||
import * as plan from "./plan.js";
|
||
import * as competitors from "./competitors.js";
|
||
import * as brandkit from "./brandkit.js";
|
||
import * as extra from "./platforms/index.js";
|
||
|
||
const server = new McpServer({ name: "social-manager", version: "0.1.0" });
|
||
|
||
const ok = (data) => ({ content: [{ type: "text", text: typeof data === "string" ? data : JSON.stringify(data, null, 2) }] });
|
||
const fail = (e) => ({ isError: true, content: [{ type: "text", text: e.message || String(e) }] });
|
||
const wrap = (fn) => async (args) => { try { return ok(await fn(args)); } catch (e) { return fail(e); } };
|
||
|
||
async function need(account) {
|
||
const a = await meta.findAccount(account);
|
||
if (!a) throw new Error(`No connected account matches "${account}". Run list_accounts to see what is connected.`);
|
||
return a;
|
||
}
|
||
|
||
function logAction(entry) {
|
||
const dir = path.join(PROJECT_ROOT, "data");
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
fs.appendFileSync(path.join(dir, "publish-log.jsonl"), JSON.stringify({ at: new Date().toISOString(), ...entry }) + "\n");
|
||
}
|
||
|
||
function publishGuard(confirm) {
|
||
if (process.env.ALLOW_PUBLISH !== "true") throw new Error("Publishing is disabled. Set ALLOW_PUBLISH=true in .env to enable it.");
|
||
if (confirm !== true) throw new Error("Publishing needs confirm=true, and only after a human has approved this exact post.");
|
||
}
|
||
|
||
const acct = z.string().describe("Client page name, Instagram @username, page ID or IG ID");
|
||
|
||
server.tool("list_accounts", "List every Facebook Page and linked Instagram account the agency can manage.", { refresh: z.boolean().optional() },
|
||
wrap(async ({ refresh }) => (await meta.listAccounts({ refresh })).map(meta.publicAccount)));
|
||
|
||
server.tool("instagram_insights", "Instagram account profile and totals (reach, views, interactions, follows...) for the last N days (max 30).",
|
||
{ account: acct, days: z.number().int().min(1).max(30).default(28) },
|
||
wrap(async ({ account, days }) => {
|
||
const a = await need(account);
|
||
if (!a.instagram) throw new Error(`${a.page_name} has no Instagram professional account linked to its Page.`);
|
||
return meta.igAccountInsights(a.instagram.id, { sinceDays: days });
|
||
}));
|
||
|
||
server.tool("instagram_recent_posts", "Recent Instagram posts with per-post reach, views, saves, shares and interactions.",
|
||
{ account: acct, limit: z.number().int().min(1).max(50).default(12) },
|
||
wrap(async ({ account, limit }) => {
|
||
const a = await need(account);
|
||
if (!a.instagram) throw new Error(`${a.page_name} has no Instagram account linked.`);
|
||
return meta.igRecentMedia(a.instagram.id, { limit });
|
||
}));
|
||
|
||
server.tool("instagram_comments", "Comments (and replies) on one Instagram post.", { media_id: z.string() },
|
||
wrap(({ media_id }) => meta.igComments(media_id)));
|
||
|
||
server.tool("facebook_page_insights", "Facebook Page followers, engagement and views for the last N days (max 90).",
|
||
{ account: acct, days: z.number().int().min(1).max(90).default(28) },
|
||
wrap(async ({ account, days }) => meta.pageInsights(await need(account), { sinceDays: days })));
|
||
|
||
server.tool("facebook_recent_posts", "Recent Facebook Page posts with reactions, comments and shares.",
|
||
{ account: acct, limit: z.number().int().min(1).max(50).default(10) },
|
||
wrap(async ({ account, limit }) => meta.pageRecentPosts(await need(account), { limit })));
|
||
|
||
server.tool("instagram_publish", "PUBLISH a post or reel to a client's Instagram. Only call after the user approved this exact caption and media. media_url must be publicly reachable.",
|
||
{ account: acct, media_url: z.string().url(), caption: z.string(), type: z.enum(["IMAGE", "REELS"]).default("IMAGE"), confirm: z.boolean() },
|
||
wrap(async ({ account, media_url, caption, type, confirm }) => {
|
||
publishGuard(confirm);
|
||
const a = await need(account);
|
||
if (!a.instagram) throw new Error(`${a.page_name} has no Instagram account linked.`);
|
||
const r = await meta.igPublish(a.instagram.id, { mediaUrl: media_url, caption, type });
|
||
logAction({ platform: "instagram", account: a.instagram.username, type, media_url, caption, result: r });
|
||
return r;
|
||
}));
|
||
|
||
server.tool("facebook_publish", "PUBLISH a post to a client's Facebook Page. Only call after the user approved this exact text/image.",
|
||
{ account: acct, message: z.string(), image_url: z.string().url().optional(), link: z.string().url().optional(), confirm: z.boolean() },
|
||
wrap(async ({ account, message, image_url, link, confirm }) => {
|
||
publishGuard(confirm);
|
||
const a = await need(account);
|
||
const r = await meta.pagePublish(a, { message, imageUrl: image_url, link });
|
||
logAction({ platform: "facebook", account: a.page_name, message, image_url, link, result: r });
|
||
return r;
|
||
}));
|
||
|
||
// ---------- Engagement ----------
|
||
server.tool("comment_inbox", "Recent Instagram + Facebook comments for a client, newest first, flagged replied/unreplied. Use to draft replies.",
|
||
{ account: acct, days: z.number().int().min(1).max(60).default(14) },
|
||
wrap(async ({ account, days }) => meta.commentInbox(await need(account), { days })));
|
||
|
||
server.tool("reply_comment", "POST a public reply to a comment. Only after a human approved the exact reply text.",
|
||
{ account: acct, platform: z.enum(["instagram", "facebook"]), comment_id: z.string(), message: z.string().min(1), confirm: z.boolean() },
|
||
wrap(async ({ account, platform, comment_id, message, confirm }) => {
|
||
publishGuard(confirm);
|
||
const a = await need(account);
|
||
const r = platform === "instagram" ? await meta.igReplyComment(comment_id, message) : await meta.fbReplyComment(a, comment_id, message);
|
||
logAction({ platform, action: "reply", account: a.page_name, comment_id, message, result: r });
|
||
return r;
|
||
}));
|
||
|
||
server.tool("hide_comment", "Hide (or unhide) a spam/abusive comment. Needs confirm=true after a human agreed.",
|
||
{ account: acct, platform: z.enum(["instagram", "facebook"]), comment_id: z.string(), hide: z.boolean().default(true), confirm: z.boolean() },
|
||
wrap(async ({ account, platform, comment_id, hide, confirm }) => {
|
||
publishGuard(confirm);
|
||
const a = await need(account);
|
||
const r = platform === "instagram" ? await meta.igHideComment(comment_id, hide) : await meta.fbHideComment(a, comment_id, hide);
|
||
logAction({ platform, action: hide ? "hide" : "unhide", account: a.page_name, comment_id, result: r });
|
||
return r;
|
||
}));
|
||
|
||
server.tool("instagram_mentions", "Posts where other accounts tagged the client on Instagram (UGC, partners, press).",
|
||
{ account: acct }, wrap(async ({ account }) => { const a = await need(account); if (!a.instagram) throw new Error("No Instagram linked."); return meta.igMentions(a.instagram.id); }));
|
||
|
||
server.tool("direct_messages", "Recent Instagram or Messenger conversations for a client (needs instagram_manage_messages / pages_messaging permissions).",
|
||
{ account: acct, platform: z.enum(["instagram", "messenger"]).default("instagram") },
|
||
wrap(async ({ account, platform }) => meta.listConversations(await need(account), { platform })));
|
||
|
||
server.tool("send_direct_message", "SEND a DM reply to someone who messaged the client (within Meta's 24-hour reply window). Only after a human approved the exact text.",
|
||
{ account: acct, recipient_id: z.string(), message: z.string().min(1), platform: z.enum(["instagram", "messenger"]).default("instagram"), confirm: z.boolean() },
|
||
wrap(async ({ account, recipient_id, message, platform, confirm }) => {
|
||
publishGuard(confirm);
|
||
const a = await need(account);
|
||
const r = await meta.sendMessage(a, recipient_id, message, { platform });
|
||
logAction({ platform, action: "dm", account: a.page_name, recipient_id, message, result: r });
|
||
return r;
|
||
}));
|
||
|
||
// ---------- Research ----------
|
||
server.tool("competitor_lookup", "Public stats + recent posts (likes, comments, format) of ANY Instagram business/creator account, e.g. a competitor. Uses the client's account to look it up.",
|
||
{ account: acct, username: z.string(), posts: z.number().int().min(1).max(50).default(12) },
|
||
wrap(async ({ account, username, posts }) => { const a = await need(account); if (!a.instagram) throw new Error("No Instagram linked."); return meta.igBusinessDiscovery(a.instagram.id, username, { posts }); }));
|
||
|
||
server.tool("audience_demographics", "Follower demographics (top cities, countries, age, gender) and, when available, the hours followers are online.",
|
||
{ account: acct }, wrap(async ({ account }) => { const a = await need(account); if (!a.instagram) throw new Error("No Instagram linked."); return meta.igAudience(a.instagram.id); }));
|
||
|
||
// ---------- Scheduling (queue → human approval → auto-publish at the client's local time) ----------
|
||
const slug = z.string().describe("Client folder slug, e.g. maison-de-treats");
|
||
const platformList = z.array(z.enum(sched.ALL_PLATFORMS)).min(1);
|
||
const options = z.record(z.string(), z.record(z.string(), z.any())).default({})
|
||
.describe("Per-platform settings, e.g. {\"youtube\":{\"title\":\"…\",\"privacy\":\"public\"},\"pinterest\":{\"boardId\":\"…\"},\"linkedin\":{\"caption\":\"…\"}}. Any platform can take its own \"caption\". See list_connections for each platform's fields.");
|
||
|
||
server.tool("list_connections", "Which platforms beyond Facebook/Instagram (LinkedIn, Google Business Profile, YouTube, TikTok, Pinterest, Threads, Reddit) are connected for a client, the account, and the per-post fields each platform needs.",
|
||
{ client: slug },
|
||
wrap(async ({ client }) => {
|
||
const conns = extra.vault.listConnections({ client });
|
||
return extra.describePlatforms().map((p) => {
|
||
const c = conns.find((x) => x.platform === p.id);
|
||
return { platform: p.id, name: p.name, connected: Boolean(c), status: c?.status || null, account: c?.account?.name || null, target: c?.target?.name || null, formats: p.formats, fields: p.fields.map((f) => ({ key: f.key, label: f.label, required: Boolean(f.required), options: f.options?.map((o) => o[0]), onlyFor: f.onlyFor })), captionLimit: p.limits.caption || null };
|
||
});
|
||
}));
|
||
|
||
server.tool("schedule_post", "Add a post to the content queue as a DRAFT (never publishes). localTime is in the CLIENT's time zone. media = local file paths or public URLs. Platforms other than instagram/facebook must be connected for the client first (list_connections).",
|
||
{ client: slug, platforms: platformList, type: z.enum(["IMAGE", "REELS", "CAROUSEL", "STORY", "TEXT"]), options,
|
||
media: z.array(z.string()).default([]), caption: z.string().default(""), fb_caption: z.string().optional(),
|
||
collaborators: z.array(z.string()).default([]).describe("Instagram usernames to invite as Collab"), local_time: z.string().describe("YYYY-MM-DD HH:MM in the client's time zone"), notes: z.string().optional() },
|
||
wrap(async (a) => sched.createPost({ client: a.client, platforms: a.platforms, type: a.type, media: a.media, caption: a.caption, fbCaption: a.fb_caption, collaborators: a.collaborators, options: a.options, localTime: a.local_time, notes: a.notes, createdBy: "agent" })));
|
||
|
||
server.tool("list_scheduled", "The content queue (drafts, approved, published, failed), optionally for one client.",
|
||
{ client: z.string().optional(), status: z.enum(["draft", "approved", "publishing", "published", "partial", "failed", "cancelled"]).optional() },
|
||
wrap(async ({ client, status }) => sched.listPosts({ client, status })));
|
||
|
||
server.tool("approve_scheduled", "APPROVE a queued draft so it publishes automatically at its time. Only when a named person has approved it in this conversation.",
|
||
{ id: z.string(), approved_by: z.string().describe("Name of the person who approved"), confirm: z.boolean() },
|
||
wrap(async ({ id, approved_by, confirm }) => { publishGuard(confirm); return sched.approvePost(id, approved_by); }));
|
||
|
||
server.tool("cancel_scheduled", "Cancel a queued or approved post (also removes it from Facebook's scheduler if it was handed over).",
|
||
{ id: z.string() }, wrap(async ({ id }) => sched.cancelPost(id, "agent")));
|
||
|
||
server.tool("suggested_post_times", "Default good posting times for a client in their own time zone.",
|
||
{ client: slug }, wrap(async ({ client }) => { const c = sched.clientInfo(client); return { client: c.name, timezone: c.timezone, times: sched.suggestedTimes(c.timezone) }; }));
|
||
|
||
|
||
server.tool("content_library", "Finished work the team uploaded for a client (designs, reels, carousels) with review status. Use status=approved to see what is ready to schedule; plan the week from this before asking the team for new work.",
|
||
{ client: z.string().optional(), status: z.enum(["submitted", "changes", "approved", "scheduled", "archived"]).optional() },
|
||
wrap(async ({ client, status }) => lib.listItems({ client, status }).map((i) => ({ id: i.id, client: i.client, title: i.title, format: i.format, files: i.files, captionIdea: i.captionIdea, notes: i.notes, forDate: i.forDate, taskRef: i.taskRef, status: i.status, version: i.version, uploadedBy: i.uploadedBy, reviewNote: i.reviewNote, updatedAt: i.updatedAt, postIds: i.postIds }))));
|
||
|
||
server.tool("schedule_from_library", "Turn an APPROVED library item into a DRAFT post in the queue (never publishes; a person still approves the post). local_time is in the client's time zone. Leave caption empty to use the uploader's caption idea, but normally write a finished caption.",
|
||
{ id: z.string(), platforms: platformList.default(["instagram", "facebook"]), local_time: z.string().describe("YYYY-MM-DD HH:MM in the client's time zone"),
|
||
caption: z.string().optional(), fb_caption: z.string().optional(), collaborators: z.array(z.string()).default([]), options },
|
||
wrap(async (a) => lib.scheduleItem(a.id, { platforms: a.platforms, localTime: a.local_time, caption: a.caption, fbCaption: a.fb_caption, collaborators: a.collaborators, options: a.options }, "agent")));
|
||
|
||
server.tool("content_plan", "A client's content calendar between two dates: plan items (idea/brief/production/ready), library items with a target date, and queued/published posts. Check this before planning so nothing is doubled.",
|
||
{ client: z.string(), from: z.string().describe("YYYY-MM-DD"), to: z.string().describe("YYYY-MM-DD") },
|
||
wrap(async ({ client, from, to }) => plan.calendar({ client, from, to })));
|
||
|
||
server.tool("plan_add", "Add items to a client's content calendar (the weekly-plan skill uses this). Each item is a planned post, not a scheduled one: it starts as an idea or brief for the team to produce. Never publishes.",
|
||
{ client: z.string(), items: z.array(z.object({
|
||
date: z.string().describe("YYYY-MM-DD in the client's time zone"), time: z.string().optional().describe("HH:MM, optional"),
|
||
title: z.string().describe("Short working title shown on the calendar card"),
|
||
format: z.enum(["IMAGE", "REELS", "CAROUSEL", "STORY", "TEXT"]).default("IMAGE"),
|
||
platforms: z.array(z.string()).default([]), status: z.enum(["idea", "brief", "production", "ready"]).default("brief"),
|
||
brief: z.string().default("").describe("Creative brief for the designer/editor"), caption: z.string().default("").describe("Draft caption"),
|
||
pillar: z.string().default(""), assignee: z.string().default(""),
|
||
})).min(1).max(30) },
|
||
wrap(async ({ client, items }) => items.map((i) => plan.createItem({ client, ...i }, "agent"))));
|
||
|
||
server.tool("plan_update", "Update a content-plan item (status, date, brief, caption, assignee...).",
|
||
{ id: z.string(), patch: z.object({ date: z.string().optional(), time: z.string().optional(), title: z.string().optional(), status: z.enum(["idea", "brief", "production", "ready"]).optional(),
|
||
format: z.enum(["IMAGE", "REELS", "CAROUSEL", "STORY", "TEXT"]).optional(), platforms: z.array(z.string()).optional(), brief: z.string().optional(), caption: z.string().optional(), assignee: z.string().optional() }) },
|
||
wrap(async ({ id, patch }) => plan.updateItem(id, patch, "agent")));
|
||
|
||
server.tool("competitors", "The Instagram competitors tracked for a client, with followers, growth, posts per week, average engagement, format mix and top posts (the dashboard's Competitors page). Use in audits instead of one-off lookups.",
|
||
{ client: z.string() },
|
||
wrap(async ({ client }) => competitors.compare(client, null)));
|
||
|
||
server.tool("competitor_add", "Start tracking an Instagram competitor for a client (public Business/Creator accounts only). Use for the 3–5 closest local competitors you identify in an audit; say why in note.",
|
||
{ client: z.string(), username: z.string(), note: z.string().default("") },
|
||
wrap(async ({ client, username, note }) => competitors.add(client, username, { by: "agent", source: "suggested", note })));
|
||
|
||
server.tool("competitor_suggest", "Suggest Instagram competitors for a client. Each handle is checked on Instagram (public Business/Creator only) and saved as a suggestion the team adds or dismisses on the Competitors page; nothing is tracked until a person adds it. Returns the real numbers for each (followers, posts/week, median engagement, engagement rate, format mix, engagement by format, top hashtags, top posts) and why any were skipped. kind: local = a business the client competes with for the same customers; benchmark = same niche elsewhere, worth learning from.",
|
||
{ client: z.string(), accounts: z.array(z.object({ username: z.string(), kind: z.enum(["local", "benchmark"]), why: z.string(), google: z.object({ rating: z.number(), reviews: z.number().optional() }).optional() })).max(15) },
|
||
wrap(async ({ client, accounts }) => competitors.suggest(client, accounts, { by: "agent" })));
|
||
|
||
server.tool("brand_kit", "A client's saved hashtag sets, approved phrases/CTAs and words to avoid. Read it before writing captions for that client.",
|
||
{ client: z.string() }, wrap(async ({ client }) => brandkit.get(client)));
|
||
|
||
server.tool("caption_check", "Check a caption against the client's brand kit and our writing rules before scheduling (agency name, avoid list, generic phrases, hashtag count, platform length).",
|
||
{ client: z.string(), caption: z.string(), platforms: z.array(z.string()).default([]) },
|
||
wrap(async ({ client, caption, platforms }) => brandkit.checkCaption(client, caption, { platforms })));
|
||
|
||
await server.connect(new StdioServerTransport());
|