metatroncubeswdev f86bd9ce33 Competitor scan: AI finds rivals, Instagram verifies, visual report
- 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>
2026-10-27 13:44:37 -04:00

230 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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());