UNPKG

@postdom/mcp

Version:

Postdom MCP server for agent-led social publishing and performance measurement.

770 lines (761 loc) 26.9 kB
// src/index.ts import { randomUUID } from "crypto"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; // ../core/src/approval.ts import { z as z2 } from "zod"; // ../core/src/platform.ts import { z } from "zod"; var corePlatformSchema = z.enum(["tiktok", "instagram", "youtube"]); var policyVisibilityOptions = { tiktok: [ "PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "FOLLOWER_OF_CREATOR", "SELF_ONLY" ], instagram: ["account_default"], youtube: ["public", "private", "unlisted"] }; var policyVisibilitySchema = z.enum([ ...policyVisibilityOptions.tiktok, ...policyVisibilityOptions.instagram, ...policyVisibilityOptions.youtube ]); var PLATFORM_LIMITS_VERIFIED_AT = "2026-08-21T00:00:00.000Z"; var PLATFORM_LIMITS_EVIDENCE = "ARCHITECTURE.md#4-data-model"; function verifiedLimit(value) { return { value, verified_at: PLATFORM_LIMITS_VERIFIED_AT, evidence: PLATFORM_LIMITS_EVIDENCE }; } var platformLimits = { global: { caption_max_chars: verifiedLimit(4e3), media_max_bytes: verifiedLimit(500 * 1024 * 1024), video_max_seconds: verifiedLimit(10 * 60), video_mime_types: verifiedLimit(["video/mp4", "video/quicktime"]) }, tiktok: { video_min_seconds: verifiedLimit(3), video_max_seconds: verifiedLimit(10 * 60) }, instagram: { caption_max_chars: verifiedLimit(2200), video_max_seconds: verifiedLimit(90) }, youtube: { title_max_chars: verifiedLimit(100), video_max_seconds: verifiedLimit(3 * 60), aspect_ratio: verifiedLimit("9:16") } }; var tiktokSettingsSchema = z.object({ privacy_level: z.enum([ "PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "FOLLOWER_OF_CREATOR", "SELF_ONLY" ]), allow_comment: z.boolean(), allow_duet: z.boolean(), allow_stitch: z.boolean(), content_preview_confirmed: z.literal(true), express_consent_given: z.literal(true), video_made_with_ai: z.boolean().default(true) }); var instagramSettingsSchema = z.object({ contentType: z.literal("reel").default("reel"), isAiGenerated: z.boolean().default(true) }); var youtubeSettingsSchema = z.object({ title: z.string().min(1).max(platformLimits.youtube.title_max_chars.value), visibility: z.enum(["public", "private", "unlisted"]).default("private"), madeForKids: z.boolean(), containsSyntheticMedia: z.boolean().default(true) }); var platformTargetSchema = z.discriminatedUnion("platform", [ z.object({ account_id: z.string().min(1), platform: z.literal("tiktok"), settings: tiktokSettingsSchema }), z.object({ account_id: z.string().min(1), platform: z.literal("instagram"), settings: instagramSettingsSchema }), z.object({ account_id: z.string().min(1), platform: z.literal("youtube"), settings: youtubeSettingsSchema }) ]); var publishRequestSchema = z.object({ post_id: z.uuid(), caption: z.string().max(platformLimits.global.caption_max_chars.value), media_url: z.url(), targets: z.array(platformTargetSchema).min(1), idempotency_key: z.string().min(8).max(255) }); // ../core/src/approval.ts var agentContextSchema = z2.object({ identity: z2.string().trim().min(1).max(120), intent: z2.string().trim().min(1).max(500) }); var approvalFeedbackCategorySchema = z2.enum([ "caption", "media", "targeting", "timing", "policy", "other" ]); var approvalFeedbackSchema = z2.object({ category: approvalFeedbackCategorySchema, text: z2.string().trim().min(1).max(1e3) }); var approvalDecisionSchema = z2.enum(["changes_requested", "rejected"]); var approvalDestinationSchema = z2.object({ account_id: z2.string().min(1), platform: corePlatformSchema, handle: z2.string().nullable(), settings: z2.record(z2.string(), z2.unknown()) }); var planStatusSchema = z2.enum([ "requires_approval", "approved", "changes_requested", "rejected", "expired", "cancelled" ]); var planAuthorizationStatusSchema = z2.enum(["applied", "not_applied"]); var planAuthorizationReasonSchema = z2.enum([ "agent_paused", "publishing_disabled", "plan_not_found", "plan_not_approved", "plan_not_started", "plan_expired", "plan_cancelled", "target_not_in_plan", "target_not_l2", "target_disconnected", "policy_not_configured", "policy_violation", "max_posts_exhausted" ]); // ../core/src/media.ts import { z as z3 } from "zod"; var MEDIA_UPLOAD_URL_TTL_SECONDS = 5 * 60; var MEDIA_UPLOAD_MAX_BYTES_PER_HOUR = platformLimits.global.media_max_bytes.value * 4; var mediaHandleSchema = z3.string().regex( /^pd_media_[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, "media_handle must be a Postdom media handle" ); var mediaUploadRequestSchema = z3.object({ content_type: z3.enum(platformLimits.global.video_mime_types.value), size_bytes: z3.number().int().positive().max(platformLimits.global.media_max_bytes.value), platforms: z3.array(corePlatformSchema).min(1).max(3) }).superRefine((value, context) => { if (new Set(value.platforms).size !== value.platforms.length) { context.addIssue({ code: "custom", path: ["platforms"], message: "Target platforms must be unique" }); } }); var mediaUploadResponseSchema = z3.object({ media_handle: mediaHandleSchema, upload_url: z3.url(), method: z3.literal("PUT"), headers: z3.object({ "Content-Type": z3.string(), "Content-Length": z3.string().regex(/^\d+$/) }), expires_at: z3.iso.datetime() }); var mediaUploadStatusSchema = z3.enum(["pending", "stored", "failed"]); var mediaStatusResponseSchema = z3.object({ media_handle: mediaHandleSchema, status: mediaUploadStatusSchema, content_type: z3.enum(platformLimits.global.video_mime_types.value), size_bytes: z3.number().int().positive(), media_url: z3.url().nullable(), failure_reason: z3.string().nullable() }); var publishMediaSourceSchema = z3.object({ video_url: z3.url().optional(), media_handle: mediaHandleSchema.optional() }).superRefine((value, context) => { if (Number(Boolean(value.video_url)) + Number(Boolean(value.media_handle)) !== 1) { context.addIssue({ code: "custom", path: ["media_handle"], message: "Provide exactly one of media_handle or video_url" }); } }); var postMediaSourceSchema = z3.object({ media_url: z3.url().optional(), media_handle: mediaHandleSchema.optional() }).superRefine((value, context) => { if (Number(Boolean(value.media_url)) + Number(Boolean(value.media_handle)) !== 1) { context.addIssue({ code: "custom", path: ["media_handle"], message: "Provide exactly one of media_handle or media_url" }); } }); var mediaOperations = { createUpload: { method: "POST", path: "/media/uploads", request: mediaUploadRequestSchema, response: mediaUploadResponseSchema }, getUpload: { method: "GET", path: "/media/{media_handle}", params: z3.object({ media_handle: mediaHandleSchema }), response: mediaStatusResponseSchema } }; // ../core/src/performance.ts import { z as z4 } from "zod"; var metricAvailabilitySchema = z4.enum([ "available", "delayed(2-3d)", "estimable", "never", "unverified" ]); var performanceMetricSchema = z4.enum([ "views", "likes", "comments", "shares", "saves", "watch_time_s", "avg_watch_pct", "completion_pct", "follower_delta" ]); var BASELINE_VERIFIED_AT = "2026-08-21T00:00:00.000Z"; var METRIC_EVIDENCE = "docs/verification/metric-availability.md"; function verified(state, verifiedAt = BASELINE_VERIFIED_AT) { return { state, verified_at: verifiedAt, evidence: METRIC_EVIDENCE }; } function unverified() { return { state: "unverified", verified_at: null, evidence: METRIC_EVIDENCE }; } var metricAvailabilityMetadata = { tiktok: { views: verified("available"), likes: verified("available"), comments: verified("available"), shares: verified("available"), saves: unverified(), watch_time_s: verified("never"), avg_watch_pct: verified("never"), completion_pct: verified("never"), follower_delta: verified("estimable") }, instagram: { views: verified("available"), likes: verified("available"), comments: verified("available"), shares: verified("available"), saves: unverified(), watch_time_s: verified("available"), avg_watch_pct: verified("estimable"), completion_pct: verified("estimable"), follower_delta: verified("estimable") }, youtube: { views: verified("available"), likes: verified("available"), comments: verified("available"), shares: verified("available"), saves: verified("never"), watch_time_s: verified("available"), avg_watch_pct: verified("delayed(2-3d)"), completion_pct: verified("delayed(2-3d)"), follower_delta: verified("estimable") } }; var metricAvailability = Object.fromEntries( Object.entries(metricAvailabilityMetadata).map(([platform, metrics]) => [ platform, Object.fromEntries( Object.entries(metrics).map(([metric, metadata]) => [metric, metadata.state]) ) ]) ); var nullableMetric = z4.number().nonnegative().nullable(); var postPerformanceSchema = z4.object({ views: z4.number().nonnegative(), likes: z4.number().nonnegative(), comments: z4.number().nonnegative(), shares: z4.number().nonnegative(), saves: z4.number().nonnegative().nullable(), watch_time_s: nullableMetric, avg_watch_pct: nullableMetric, completion_pct: nullableMetric, follower_delta: z4.number().nullable(), captured_at: z4.iso.datetime(), platform: corePlatformSchema, source: z4.enum(["webhook", "poll"]), change_since_last: z4.record(z4.string(), z4.number().nullable()).optional() }); // ../core/src/webhooks.ts import { z as z5 } from "zod"; var outboundWebhookEventTypeSchema = z5.enum([ "post.published", "post.failed", "performance.updated" ]); var outboundWebhookEndpointCreateSchema = z5.object({ url: z5.url(), events: z5.array(outboundWebhookEventTypeSchema).min(1).max(3) }).superRefine((value, context) => { if (new Set(value.events).size !== value.events.length) { context.addIssue({ code: "custom", path: ["events"], message: "Webhook event types must be unique" }); } }); var outboundWebhookEndpointUpdateSchema = z5.object({ url: z5.url().optional(), events: z5.array(outboundWebhookEventTypeSchema).min(1).max(3).optional(), enabled: z5.boolean().optional() }).refine((value) => Object.keys(value).length > 0, { message: "At least one webhook endpoint field is required" }).superRefine((value, context) => { if (value.events && new Set(value.events).size !== value.events.length) { context.addIssue({ code: "custom", path: ["events"], message: "Webhook event types must be unique" }); } }); var outboundWebhookEndpointSchema = z5.object({ id: z5.uuid(), url: z5.url(), events: z5.array(outboundWebhookEventTypeSchema), enabled: z5.boolean(), created_at: z5.iso.datetime(), updated_at: z5.iso.datetime() }); var outboundWebhookEndpointCreatedSchema = outboundWebhookEndpointSchema.extend({ signing_secret: z5.string().startsWith("whsec_") }); var outboundWebhookDeliveryStateSchema = z5.enum([ "pending", "sending", "retry", "delivered", "exhausted", "cancelled" ]); var outboundWebhookDeliverySchema = z5.object({ id: z5.uuid(), event_id: z5.uuid(), event_type: outboundWebhookEventTypeSchema, endpoint_id: z5.uuid(), state: outboundWebhookDeliveryStateSchema, attempts: z5.number().int().nonnegative(), next_attempt_at: z5.iso.datetime(), delivered_at: z5.iso.datetime().nullable(), last_http_status: z5.number().int().nullable(), last_error: z5.string().nullable(), created_at: z5.iso.datetime() }); // src/index.ts import { z as z6 } from "zod"; var POSTDOM_MCP_VERSION = "0.3.0"; var performanceMetricSchema2 = z6.enum([ "views", "likes", "comments", "shares", "saves", "watch_time_s", "avg_watch_pct", "completion_pct", "follower_delta" ]); var POSTDOM_MCP_TOOL_NAMES = [ "get_workspace_status", "get_brief", "get_digest", "list_accounts", "connect_account", "upload_media", "get_media", "publish_video", "submit_plan", "get_plan", "get_publish", "get_performance", "get_best_posts" ]; var PostdomClient = class { constructor(options) { this.options = options; this.baseUrl = options.baseUrl ?? "https://api.postdom.com/v1"; this.fetch = options.fetch ?? globalThis.fetch; } options; baseUrl; fetch; async request(path, init = {}) { const headers = new Headers(init.headers); headers.set("Authorization", `Bearer ${this.options.apiKey}`); headers.set("Accept", "application/json"); if (init.body) headers.set("Content-Type", "application/json"); const response = await this.fetch(`${this.baseUrl}${path}`, { ...init, headers }); const payload = await response.json(); if (!response.ok) { const problem = typeof payload === "object" && payload !== null ? payload : {}; const code = typeof problem.code === "string" ? ` ${problem.code}` : ""; const detail = typeof problem.detail === "string" ? `: ${problem.detail}` : ""; throw new Error(`Postdom API request failed with HTTP ${response.status}${code}${detail}`); } return payload; } async listAccounts() { const response = await this.request("/accounts"); return response.accounts; } getWorkspaceStatus() { return this.request("/workspace/status"); } connectAccount(platform) { return this.request("/accounts/connect", { method: "POST", body: JSON.stringify({ platform, redirect_url: "https://app.postdom.com/accounts" }) }); } async createMediaUpload(input) { const request = mediaUploadRequestSchema.parse(input); const response = await this.request(mediaOperations.createUpload.path, { method: mediaOperations.createUpload.method, body: JSON.stringify(request) }); return mediaUploadResponseSchema.parse(response); } async getMedia(mediaHandle) { const { media_handle } = mediaOperations.getUpload.params.parse({ media_handle: mediaHandle }); const response = await this.request( mediaOperations.getUpload.path.replace( "{media_handle}", encodeURIComponent(media_handle) ) ); return mediaStatusResponseSchema.parse(response); } async publishVideo(input) { const mediaSource = publishMediaSourceSchema.parse({ video_url: input.videoUrl, media_handle: input.mediaHandle }); const accounts = await this.listAccounts(); const byId = new Map(accounts.map((account) => [account.providerAccountId, account])); const targets = input.accountIds.map((accountId) => { const account = byId.get(accountId); if (!account) throw new Error(`Postdom account ${accountId} is not connected`); return defaultTarget(account.platform, accountId, input.caption); }); return this.request("/posts", { method: "POST", headers: { "Idempotency-Key": input.idempotencyKey ?? randomUUID(), "X-Postdom-Source": "mcp" }, body: JSON.stringify({ caption: input.caption, ...mediaSource.video_url ? { media_url: mediaSource.video_url } : {}, ...mediaSource.media_handle ? { media_handle: mediaSource.media_handle } : {}, publish_at: input.publishAt, plan_id: input.planId, targets, agent_context: { identity: this.options.agentIdentity ?? input.agentIdentity ?? "AI agent via MCP", intent: input.intent } }) }); } async submitPlan(input) { const accounts = await this.listAccounts(); const byId = new Map(accounts.map((account) => [account.providerAccountId, account])); const targets = input.accountIds.map((accountId) => { const account = byId.get(accountId); if (!account) throw new Error(`Postdom account ${accountId} is not connected`); return { account_id: accountId, platform: account.platform }; }); return this.request("/plans", { method: "POST", headers: { "Idempotency-Key": input.idempotencyKey ?? randomUUID(), "X-Postdom-Source": "mcp" }, body: JSON.stringify({ title: input.title, objective: input.objective, starts_at: input.startsAt, ends_at: input.endsAt, max_posts: input.maxPosts, brief_version: input.briefVersion, targets, agent_context: { identity: this.options.agentIdentity ?? input.agentIdentity ?? "AI agent via MCP", intent: input.intent } }) }); } getPlan(planId) { return this.request(`/plans/${encodeURIComponent(planId)}`); } getBrief() { return this.request("/brief"); } getDigest() { return this.request("/digest"); } getPublish(postId) { return this.request(`/posts/${encodeURIComponent(postId)}`); } getPostPerformance(postId) { return this.request(`/posts/${encodeURIComponent(postId)}/performance`); } getAccountPerformance(accountId, window) { return this.request( `/accounts/${encodeURIComponent(accountId)}/performance?window=${window}` ); } getBestPosts(accountId, metric, window) { return this.request( `/accounts/${encodeURIComponent(accountId)}/best-posts?metric=${encodeURIComponent(metric)}&window=${window}` ); } }; function defaultTarget(platform, accountId, caption) { if (platform === "tiktok") { return { account_id: accountId, platform, settings: { privacy_level: "SELF_ONLY", allow_comment: false, allow_duet: false, allow_stitch: false, content_preview_confirmed: true, express_consent_given: true, video_made_with_ai: true } }; } if (platform === "instagram") { return { account_id: accountId, platform, settings: { contentType: "reel", isAiGenerated: true } }; } if (platform === "youtube") { return { account_id: accountId, platform, settings: { title: caption.split("\n")[0]?.slice(0, 100) || "Postdom video", visibility: "private", madeForKids: false, containsSyntheticMedia: true } }; } throw new Error(`publish_video does not support ${platform} in M2`); } function toolResult(value) { return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], structuredContent: value }; } function createPostdomMcpServer(client) { const server = new McpServer({ name: "postdom", version: POSTDOM_MCP_VERSION }); server.registerTool("get_workspace_status", { title: "Get workspace status", description: "Start here. Read connected-account health, autonomy and policy state, brief version, activity counts, and the connection gate.", inputSchema: {}, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false } }, async () => toolResult(await client.getWorkspaceStatus())); server.registerTool("get_brief", { title: "Get workspace brief", description: "Read the current workspace-owned brand guidance before planning or writing content.", inputSchema: {}, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false } }, async () => toolResult(await client.getBrief())); server.registerTool("get_digest", { title: "Get latest learning digest", description: "Read the latest completed weekly workspace digest before planning. It describes observed outcomes, populations, cadence endings, and coverage gaps; it does not recommend actions.", inputSchema: {}, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false } }, async () => toolResult(await client.getDigest())); server.registerTool("list_accounts", { title: "List connected accounts", description: "List the destination accounts available inside this workspace.", inputSchema: {}, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false } }, async () => toolResult({ accounts: await client.listAccounts() })); server.registerTool("connect_account", { title: "Connect social account", description: "Create a platform OAuth URL. Hand the returned URL to the human; never ask for or handle their social password.", inputSchema: { platform: z6.enum(["tiktok", "instagram", "youtube"]) }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true } }, async ({ platform }) => toolResult(await client.connectAccount(platform))); server.registerTool("upload_media", { title: "Upload media", description: "Create a short-lived, workspace-scoped PUT URL for finished video bytes. Upload the exact declared bytes directly to the returned URL, then call get_media until the handle is stored.", inputSchema: mediaUploadRequestSchema.shape, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true } }, async (input) => toolResult(await client.createMediaUpload(input))); server.registerTool("get_media", { title: "Get media", description: "Verify one workspace media handle after its direct PUT. Continue only when status is stored; preserve pending or failed exactly.", inputSchema: { media_handle: mediaHandleSchema }, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false } }, async ({ media_handle }) => toolResult(await client.getMedia(media_handle))); server.registerTool("publish_video", { title: "Publish video", description: "Create a private, AI-disclosed short-form video publish, optionally scheduled in UTC. Posts flow automatically inside an account's policy or await review on review-mode accounts.", inputSchema: { account_ids: z6.array(z6.string()).min(1), video_url: z6.url().optional(), media_handle: mediaHandleSchema.optional(), caption: z6.string().max(4e3), intent: z6.string().trim().min(1).max(500), agent_identity: z6.string().trim().min(1).max(120).optional(), publish_at: z6.string().datetime().optional(), plan_id: z6.string().uuid().optional(), idempotency_key: z6.string().min(1).optional() }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true } }, async ({ account_ids, video_url, media_handle, caption, intent, agent_identity, publish_at, plan_id, idempotency_key }) => { publishMediaSourceSchema.parse({ video_url, media_handle }); return toolResult(await client.publishVideo({ accountIds: account_ids, videoUrl: video_url, mediaHandle: media_handle, caption, intent, agentIdentity: agent_identity, publishAt: publish_at, planId: plan_id, idempotencyKey: idempotency_key })); }); server.registerTool("submit_plan", { title: "Submit publication plan", description: "Submit a time-bounded L2 publication plan for one human approval.", inputSchema: { account_ids: z6.array(z6.string()).min(1), title: z6.string().trim().min(1).max(120), objective: z6.string().trim().min(1).max(1e3), starts_at: z6.string().datetime(), ends_at: z6.string().datetime(), max_posts: z6.number().int().min(1).max(20), brief_version: z6.number().int().positive().optional(), intent: z6.string().trim().min(1).max(500), agent_identity: z6.string().trim().min(1).max(120).optional(), idempotency_key: z6.string().min(1).optional() }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false } }, async ({ account_ids, title, objective, starts_at, ends_at, max_posts, brief_version, intent, agent_identity, idempotency_key }) => toolResult( await client.submitPlan({ accountIds: account_ids, title, objective, startsAt: starts_at, endsAt: ends_at, maxPosts: max_posts, briefVersion: brief_version, intent, agentIdentity: agent_identity, idempotencyKey: idempotency_key }) )); server.registerTool("get_plan", { title: "Get publication plan", description: "Read plan status and structured feedback from the human reviewer.", inputSchema: { plan_id: z6.string().uuid() }, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false } }, async ({ plan_id }) => toolResult(await client.getPlan(plan_id))); server.registerTool("get_publish", { title: "Get publish", description: "Read publish state and any structured approval feedback returned by the human reviewer.", inputSchema: { post_id: z6.string().min(1) }, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false } }, async ({ post_id }) => toolResult(await client.getPublish(post_id))); server.registerTool("get_performance", { title: "Get performance", description: "Read normalized performance snapshots for one post or connected account.", inputSchema: { post_id: z6.string().optional(), account_id: z6.string().optional(), window: z6.enum(["7d", "30d"]).default("7d") }, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false } }, async ({ post_id, account_id, window }) => { if (Boolean(post_id) === Boolean(account_id)) { throw new Error("Provide exactly one of post_id or account_id"); } const result = post_id ? await client.getPostPerformance(post_id) : await client.getAccountPerformance(account_id, window); return toolResult(result); }); server.registerTool("get_best_posts", { title: "Get best posts", description: "Rank one connected account's posts by an evidence-backed metric over the requested window. Null observations are excluded with reasons, never ranked as zero.", inputSchema: { account_id: z6.string().min(1), metric: performanceMetricSchema2, window: z6.enum(["7d", "30d"]).default("7d") }, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false } }, async ({ account_id, metric, window }) => toolResult( await client.getBestPosts(account_id, metric, window) )); return server; } async function handlePostdomMcpHttpRequest(request, options) { const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: void 0, enableJsonResponse: true }); const server = createPostdomMcpServer(new PostdomClient(options)); await server.connect(transport); return transport.handleRequest(request); } export { POSTDOM_MCP_VERSION, POSTDOM_MCP_TOOL_NAMES, PostdomClient, createPostdomMcpServer, handlePostdomMcpHttpRequest };