Files
T
WorkBuddy df56c2c137 初始化提交:contentm_agent 工作区全量快照
内容分四块:
1、产品规划产出 —— MCN 短视频整合营销工作台的①段五份(1a 需求/1b 竞品/1c 画像/1d 策略/1e 场景)、②段两份(2a 功能/2b 布局)、③段界面(DESIGN.md 契约与令牌表 + mcn-workbench.html 原型 + 实测/会诊/审查三份 + 23 张闸门截图)。
2、开源竞品调研 —— 5 个内容工作台项目的取证原始件与 1b 系列分析文档。
3、参考资料 —— 竞品视频抽帧 1145 张 + 2 个源视频 + 功能点截图。
4、机制侧 —— 协作脚本与状态台账、工作区记忆日志、抽帧/OCR 脚本。

.gitignore 只排运行时日志、脚本备份副本与一次性探针输出,其余按原样入库。
2026-10-08 08:13:02 +08:00

692 lines
23 KiB
TypeScript

/**
* PostSider MCP server factory.
*
* Owns every tool registration so all transports (today: stdio; later: a remote
* Streamable HTTP server) share exactly one agent-facing surface. The factory
* takes an injected client and reads no environment variable, which keeps it
* importable from tests and from any entrypoint without side effects.
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { PostsiderClient } from './client.js';
import { buildCreatePostBody } from './post-body.js';
import { registerPostSiderSkills } from './skills-catalog.js';
export const POSTSIDER_MCP_SERVER_NAME = 'postsider';
export const POSTSIDER_MCP_SERVER_VERSION = '1.0.0';
/** Wrap a tool handler so any error becomes an actionable MCP error result. */
function ok(data: unknown) {
return {
content: [
// Compact, not pretty-printed: every tool result is injected into the
// agent's context, and an indented body is roughly twice the tokens for
// no gain the agent can use.
{ type: 'text' as const, text: JSON.stringify(data) },
],
};
}
function fail(err: unknown) {
return {
isError: true,
content: [
{
type: 'text' as const,
text: err instanceof Error ? err.message : String(err),
},
],
};
}
const mediaItem = z
.object({
id: z.string().optional(),
path: z.string().min(1).describe('Media path/URL returned by an upload tool.'),
})
.passthrough();
/**
* ISO 8601 calendar date or date-time. Mirrors the API's `@IsDateString()`,
* which accepts both `2026-06-01` and `2026-06-01T00:00:00Z`; validating any
* harder here would reject payloads the API itself serves.
*/
const isoDate = z
.string()
.regex(
/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:?\d{2})?)?$/,
'Must be an ISO 8601 date (2026-06-01) or date-time (2026-06-01T00:00:00Z).'
)
.refine(isRealCalendarValue, {
message:
'Must be a date that exists, e.g. 2026-06-01 (month 1-12, a real day for that month, time 00:00:00-23:59:59).',
});
/**
* The regex above checks the shape; this checks that the value is a real moment.
* `2026-13-45` and `2026-02-30` both pass a shape test and would otherwise fail
* server-side with an opaque 400 instead of a local, fixable message.
*/
function isRealCalendarValue(value: string): boolean {
const match = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(
value
);
if (!match) {
return false;
}
const [, year, month, day, hour, minute, second] = match;
const monthNumber = Number(month);
const dayNumber = Number(day);
if (monthNumber < 1 || monthNumber > 12) {
return false;
}
// Day 0 of the following month is the last day of this one.
const lastDay = new Date(Date.UTC(Number(year), monthNumber, 0)).getUTCDate();
if (dayNumber < 1 || dayNumber > lastDay) {
return false;
}
if (hour !== undefined && Number(hour) > 23) {
return false;
}
if (minute !== undefined && Number(minute) > 59) {
return false;
}
if (second !== undefined && Number(second) > 59) {
return false;
}
return true;
}
/**
* Public HTTPS URL. The API additionally rejects internal addresses and
* non-media paths (its SSRF guard); those checks stay server-side.
*/
const httpsUrl = z
.string()
.url()
.refine((value) => new URL(value).protocol === 'https:', {
message: 'Must be a public HTTPS URL.',
});
/** Non-empty identifier, so a blank id fails locally instead of at the API. */
const nonEmptyId = z.string().min(1, 'Must be a non-empty id.');
/** Build the PostSider MCP server bound to `client`. */
export function createPostSiderMcpServer(client: PostsiderClient): McpServer {
const server = new McpServer(
{
name: POSTSIDER_MCP_SERVER_NAME,
version: POSTSIDER_MCP_SERVER_VERSION,
},
{
instructions:
"Use PostSider for the authenticated user's social publishing workspace. Follow the user's explicit requested outcome. Resolve channel, post and customer ids from fresh read calls; never invent them. Before reporting a write as successful, read the created or changed record back. Treat delete and organization-wide pause as destructive actions.",
}
);
registerPostSiderSkills(server);
// ───────────────────────────────────────────────────────────────────────────
// Channels & scheduling helpers (read-only)
// ───────────────────────────────────────────────────────────────────────────
server.registerTool(
'postsider_list_channels',
{
title: 'List channels',
description:
'List the social channels (integrations) connected to this PostSider organization. Returns each channel id, name and platform. Call this first to get the channel ids needed by postsider_create_post.',
inputSchema: {},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async () => {
try {
return ok(await client.get('/integrations'));
} catch (e) {
return fail(e);
}
}
);
server.registerTool(
'postsider_get_agency_overview',
{
title: 'Get agency overview',
description:
'Get an organization-wide operational overview for an agency: clients, channels, queued posts, drafts, published posts, errors, recent errors and pending approvals. Useful for morning checks and client reporting.',
inputSchema: {
days: z.number().int().positive().max(365).default(30).describe('Window for recent errors, in days.'),
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async ({ days }) => {
try {
return ok(await client.get('/overview', { days }));
} catch (e) {
return fail(e);
}
}
);
server.registerTool(
'postsider_get_customer_report',
{
title: 'Get customer report',
description: 'Get a customer-scoped agency report with channels, queued, draft, published, error and pending approval counts.',
inputSchema: {
customerId: nonEmptyId.describe('Customer id from postsider_list_groups.'),
days: z.number().int().positive().max(365).default(30).describe('Window for recent errors, in days.'),
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async ({ customerId, days }) => {
try {
return ok(await client.get(`/customers/${encodeURIComponent(customerId)}/report`, { days }));
} catch (e) {
return fail(e);
}
}
);
server.registerTool(
'postsider_list_groups',
{
title: 'List channel groups',
description:
'List configured channel groups. In PostSider a group is a customer, so an id returned here is the customerId expected by postsider_get_customer_report and by the customer filter of postsider_list_posts.',
inputSchema: {},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async () => {
try {
return ok(await client.get('/groups'));
} catch (e) {
return fail(e);
}
}
);
server.registerTool(
'postsider_find_slot',
{
title: 'Find next free time slot',
description:
'Return the next free scheduling date-time (UTC) for a channel, based on its configured posting queue. Use the returned value as the `date` for postsider_create_post when scheduling into the queue.',
inputSchema: {
channelId: nonEmptyId.describe(
'Channel (integration) id, from postsider_list_channels.'
),
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async ({ channelId }) => {
try {
return ok(await client.get(`/find-slot/${encodeURIComponent(channelId)}`));
} catch (e) {
return fail(e);
}
}
);
server.registerTool(
'postsider_list_posts',
{
title: 'List posts',
description:
'List posts scheduled or published within a date range (UTC). Useful for reviewing the content calendar before scheduling more.',
inputSchema: {
startDate: isoDate.describe('Range start, ISO 8601 (e.g. 2026-06-01T00:00:00Z).'),
endDate: isoDate.describe('Range end, ISO 8601 (e.g. 2026-06-30T23:59:59Z).'),
customer: nonEmptyId
.optional()
.describe('Optional customer id to filter by.'),
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async ({ startDate, endDate, customer }) => {
try {
return ok(await client.get('/posts', { startDate, endDate, customer }));
} catch (e) {
return fail(e);
}
}
);
server.registerTool(
'postsider_get_post_missing_fields',
{
title: 'Check post for missing fields',
description:
'Return per-channel validation problems / missing required fields for a post, so they can be fixed before publishing.',
inputSchema: {
postId: nonEmptyId.describe('Post id.'),
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async ({ postId }) => {
try {
return ok(await client.get(`/posts/${encodeURIComponent(postId)}/missing`));
} catch (e) {
return fail(e);
}
}
);
server.registerTool(
'postsider_get_post',
{
title: 'Get post details',
description:
'Get the full organization-scoped post group, including current state, scheduled time, media, channel and publish error. Use this to inspect the result of an asynchronous create or publish operation.',
inputSchema: {
postId: nonEmptyId.describe('Post id returned by postsider_create_post.'),
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async ({ postId }) => {
try {
return ok(await client.get(`/posts/${encodeURIComponent(postId)}`));
} catch (e) {
return fail(e);
}
}
);
server.registerTool(
'postsider_get_post_analytics',
{
title: 'Get post analytics',
description:
'Get performance analytics for a single post over the last N days (where the provider supports it).',
inputSchema: {
postId: nonEmptyId.describe('Post id.'),
days: z
.number()
.int()
.positive()
.default(7)
.describe('Look-back window in days (default 7).'),
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async ({ postId, days }) => {
try {
return ok(
await client.get(`/analytics/post/${encodeURIComponent(postId)}`, {
date: days,
})
);
} catch (e) {
return fail(e);
}
}
);
server.registerTool(
'postsider_get_channel_analytics',
{
title: 'Get channel analytics',
description:
'Get account-level analytics for a connected channel (where the provider supports it).',
inputSchema: {
channelId: nonEmptyId.describe(
'Channel (integration) id, from postsider_list_channels.'
),
date: z
.string()
.optional()
.describe('Provider-specific date/range parameter, if required.'),
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async ({ channelId, date }) => {
try {
return ok(
await client.get(`/analytics/${encodeURIComponent(channelId)}`, { date })
);
} catch (e) {
return fail(e);
}
}
);
server.registerTool(
'postsider_get_notifications',
{
title: 'Get notifications',
description:
'List recent notifications for the organization (e.g. publish failures, channels needing reconnection).',
inputSchema: {},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async () => {
try {
return ok(await client.get('/notifications'));
} catch (e) {
return fail(e);
}
}
);
// ───────────────────────────────────────────────────────────────────────────
// Emergency Pause (kill switch)
// ───────────────────────────────────────────────────────────────────────────
server.registerTool(
'postsider_pause_publishing',
{
title: 'Pause all publishing',
description:
'Immediately halt ALL publishing for this organization (kill switch): no `now` or `schedule` posts can be created, and queued posts are parked to HELD instead of going out. Use when something is wrong, for example a PR crisis, a post on the wrong channel, or a runaway automation loop. Resume is human-only (owner, dashboard); this cannot be undone via the API.',
inputSchema: {
reason: z
.string()
.optional()
.describe('Optional reason, recorded in the audit trail and shown to the team.'),
},
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
openWorldHint: true,
},
},
async ({ reason }) => {
try {
return ok(await client.post('/publishing/pause', { reason }));
} catch (e) {
return fail(e);
}
}
);
server.registerTool(
'postsider_get_publishing_state',
{
title: 'Get publishing state',
description:
'Check whether publishing is active or paused for this organization. Useful before scheduling (a paused org rejects new posts with publishing_paused), and to confirm a kill switch is on.',
inputSchema: {},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async () => {
try {
return ok(await client.get('/publishing/state'));
} catch (e) {
return fail(e);
}
}
);
// ───────────────────────────────────────────────────────────────────────────
// Media
// ───────────────────────────────────────────────────────────────────────────
server.registerTool(
'postsider_upload_media_from_url',
{
title: 'Upload media from URL',
description:
'Download an image or video from a public URL and store it in the PostSider media library. Returns a media object; pass it (or its array) as `images` to postsider_create_post.',
inputSchema: {
url: httpsUrl.describe('Public HTTPS URL of the image or video to import.'),
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true,
},
},
async ({ url }) => {
try {
return ok(await client.post('/upload-from-url', { url }));
} catch (e) {
return fail(e);
}
}
);
// ───────────────────────────────────────────────────────────────────────────
// Posting (write)
// ───────────────────────────────────────────────────────────────────────────
server.registerTool(
'postsider_create_post',
{
title: 'Create / schedule / publish a post',
description:
'Create a post across one or more channels. `type`: "schedule" books it for `date`; "now" publishes immediately; "draft" saves without publishing. Get channel ids from postsider_list_channels and a free slot from postsider_find_slot. Attach media via postsider_upload_media_from_url first, then pass the returned media objects as `images`.',
inputSchema: {
type: z
.enum(['draft', 'schedule', 'now'])
.default('schedule')
.describe('draft = save only; schedule = book for `date`; now = publish immediately.'),
date: isoDate.describe(
'Required publish date, ISO 8601 UTC (e.g. 2026-07-01T10:00:00Z). For "now" use the current time; drafts still require a date in the current API contract.'
),
shortLink: z
.boolean()
.default(false)
.describe('Whether to shorten links in the content.'),
posts: z
.array(
z.object({
channelId: nonEmptyId.describe('Channel (integration) id to publish to.'),
content: z.string().describe('The post text/caption.'),
firstComment: z
.string()
.optional()
.describe('Optional first comment posted right after (where supported).'),
images: z
.array(mediaItem)
.optional()
.describe('Optional media objects from postsider_upload_media_from_url.'),
settings: z
.record(z.any())
.optional()
.describe(
'Provider-specific settings. Required fields: X: who_can_reply_post (everyone, following, mentionedUsers, subscribers, or verified). Instagram: post_type (post or story). YouTube: title and type (public, private, or unlisted). TikTok: privacy_level (PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, or SELF_ONLY); duet, stitch, comment, brand_content_toggle, and brand_organic_toggle (booleans); autoAddMusic (yes or no); and content_posting_method (DIRECT_POST or UPLOAD). Other providers may impose additional requirements; preserve settings returned by postsider_get_post when replacing an existing post.'
),
})
)
.min(1)
.describe('One entry per channel to publish to.'),
tags: z
.array(z.object({ value: z.string(), label: z.string() }))
.optional()
.describe('Optional tags.'),
idempotencyKey: z
.string()
.min(1)
.max(255)
.optional()
.describe('Stable key for safe retries. Reusing it with the same request returns the original result without creating duplicates.'),
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true,
},
},
async ({ type, date, shortLink, posts, tags, idempotencyKey }) => {
try {
const body = buildCreatePostBody({ type, date, shortLink, posts, tags });
return ok(await client.post('/posts', body, idempotencyKey));
} catch (e) {
return fail(e);
}
}
);
server.registerTool(
'postsider_update_post_status',
{
title: 'Update post status',
description:
'Change the status of an existing post (e.g. move between draft and queue).',
inputSchema: {
postId: nonEmptyId.describe('Post id.'),
status: z
.enum(['draft', 'schedule'])
.describe('New status value: draft or schedule.'),
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async ({ postId, status }) => {
try {
return ok(
await client.put(`/posts/${encodeURIComponent(postId)}/status`, { status })
);
} catch (e) {
return fail(e);
}
}
);
server.registerTool(
'postsider_delete_post',
{
title: 'Delete a post',
description:
'Permanently delete a post and every other channel version of it. The id may be any post in a group: PostSider stores a multi-channel post as one group, and a single id deletes the whole group. Read the post first (postsider_get_post) and confirm the full set of channel versions with the user before calling this.',
inputSchema: {
postId: nonEmptyId.describe('Post id to delete.'),
},
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
openWorldHint: true,
},
},
async ({ postId }) => {
try {
await client.del(`/posts/${encodeURIComponent(postId)}`);
return ok({ deleted: true, postId });
} catch (e) {
return fail(e);
}
}
);
server.registerTool(
'postsider_request_approval',
{
title: 'Send a draft for approval',
description:
'Push a draft post into the human approval queue for review, instead of publishing or scheduling it directly. The post must already exist as a draft (see postsider_create_post with type "draft"). Approval is optional in PostSider, and most posts can also be scheduled directly without ever going through this.',
inputSchema: {
postId: nonEmptyId.describe('Draft post id to submit for approval.'),
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async ({ postId }) => {
try {
return ok(
await client.post(`/posts/${encodeURIComponent(postId)}/request-approval`)
);
} catch (e) {
return fail(e);
}
}
);
server.registerTool(
'postsider_get_approval_status',
{
title: 'Get a post\'s approval status',
description:
'Check whether a post submitted via postsider_request_approval has been approved, rejected (with the reviewer\'s note, if any), or is still pending. Returns status "NONE" if the post was never sent for approval.',
inputSchema: {
postId: nonEmptyId.describe('Post id to check.'),
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async ({ postId }) => {
try {
return ok(await client.get(`/posts/${encodeURIComponent(postId)}/approval`));
} catch (e) {
return fail(e);
}
}
);
return server;
}