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