posterly-mcp-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { PosterlyClient } from './lib/api-client.js';
5
+ import { listAccountsTool } from './tools/list-accounts.js';
6
+ import { createPostTool } from './tools/create-post.js';
7
+ import { findSlotTool } from './tools/find-slot.js';
8
+ import { listPostsTool } from './tools/list-posts.js';
9
+ import { uploadMediaTool } from './tools/upload-media.js';
10
+ const server = new McpServer({
11
+ name: 'posterly',
12
+ version: '0.1.0',
13
+ });
14
+ let client;
15
+ try {
16
+ client = new PosterlyClient();
17
+ }
18
+ catch (err) {
19
+ console.error(err.message);
20
+ process.exit(1);
21
+ }
22
+ // Register tools
23
+ server.tool(listAccountsTool.name, listAccountsTool.description, listAccountsTool.inputSchema.shape, async () => {
24
+ try {
25
+ const text = await listAccountsTool.execute(client);
26
+ return { content: [{ type: 'text', text }] };
27
+ }
28
+ catch (err) {
29
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
30
+ }
31
+ });
32
+ server.tool(createPostTool.name, createPostTool.description, createPostTool.inputSchema.shape, async (input) => {
33
+ try {
34
+ const text = await createPostTool.execute(client, input);
35
+ return { content: [{ type: 'text', text }] };
36
+ }
37
+ catch (err) {
38
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
39
+ }
40
+ });
41
+ server.tool(findSlotTool.name, findSlotTool.description, findSlotTool.inputSchema.shape, async (input) => {
42
+ try {
43
+ const text = await findSlotTool.execute(client, input);
44
+ return { content: [{ type: 'text', text }] };
45
+ }
46
+ catch (err) {
47
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
48
+ }
49
+ });
50
+ server.tool(listPostsTool.name, listPostsTool.description, listPostsTool.inputSchema.shape, async (input) => {
51
+ try {
52
+ const text = await listPostsTool.execute(client, input);
53
+ return { content: [{ type: 'text', text }] };
54
+ }
55
+ catch (err) {
56
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
57
+ }
58
+ });
59
+ server.tool(uploadMediaTool.name, uploadMediaTool.description, uploadMediaTool.inputSchema.shape, async (input) => {
60
+ try {
61
+ const text = await uploadMediaTool.execute(client, input);
62
+ return { content: [{ type: 'text', text }] };
63
+ }
64
+ catch (err) {
65
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
66
+ }
67
+ });
68
+ // Start the server
69
+ async function main() {
70
+ const transport = new StdioServerTransport();
71
+ await server.connect(transport);
72
+ }
73
+ main().catch((err) => {
74
+ console.error('Fatal error:', err);
75
+ process.exit(1);
76
+ });
@@ -0,0 +1,66 @@
1
+ export interface Account {
2
+ id: string;
3
+ platform: string;
4
+ username: string;
5
+ account_type?: string;
6
+ profile_picture_url?: string;
7
+ }
8
+ export interface Post {
9
+ id: number;
10
+ content: string;
11
+ scheduled_at: string;
12
+ social_account_id: string;
13
+ media_url?: string;
14
+ media_urls?: string[];
15
+ status: string;
16
+ post_type?: string;
17
+ platform_post_url?: string;
18
+ published_at?: string;
19
+ }
20
+ export interface Slot {
21
+ time: string;
22
+ local_time: string;
23
+ }
24
+ export declare class PosterlyClient {
25
+ private baseUrl;
26
+ private apiKey;
27
+ constructor(apiKey?: string, baseUrl?: string);
28
+ private request;
29
+ listAccounts(): Promise<Account[]>;
30
+ listPosts(params?: {
31
+ status?: string;
32
+ platform?: string;
33
+ limit?: number;
34
+ offset?: number;
35
+ }): Promise<{
36
+ posts: Post[];
37
+ total: number;
38
+ }>;
39
+ createPost(data: {
40
+ account_id?: string;
41
+ username?: string;
42
+ platform?: string;
43
+ caption: string;
44
+ scheduled_at?: string;
45
+ post_type?: string;
46
+ media_url?: string;
47
+ media_urls?: string[];
48
+ metadata?: Record<string, unknown>;
49
+ }): Promise<{
50
+ post: Post;
51
+ }>;
52
+ findAvailableSlots(params?: {
53
+ account_ids?: string[];
54
+ timezone?: string;
55
+ count?: number;
56
+ }): Promise<Slot[]>;
57
+ uploadMedia(input: {
58
+ filePath?: string;
59
+ base64Data?: string;
60
+ filename: string;
61
+ contentType?: string;
62
+ }): Promise<{
63
+ url: string;
64
+ path: string;
65
+ }>;
66
+ }
@@ -0,0 +1,99 @@
1
+ import { readFile } from 'fs/promises';
2
+ import { resolve } from 'path';
3
+ export class PosterlyClient {
4
+ baseUrl;
5
+ apiKey;
6
+ constructor(apiKey, baseUrl) {
7
+ this.apiKey = apiKey || process.env.POSTERLY_API_KEY || '';
8
+ this.baseUrl = (baseUrl || process.env.POSTERLY_URL || 'https://posterly.app').replace(/\/$/, '');
9
+ if (!this.apiKey) {
10
+ throw new Error('POSTERLY_API_KEY is required. Set it as an environment variable.');
11
+ }
12
+ }
13
+ async request(method, path, body) {
14
+ const url = `${this.baseUrl}/api/v1${path}`;
15
+ const headers = {
16
+ Authorization: `Bearer ${this.apiKey}`,
17
+ };
18
+ const init = { method, headers };
19
+ if (body instanceof FormData) {
20
+ init.body = body;
21
+ }
22
+ else if (body) {
23
+ headers['Content-Type'] = 'application/json';
24
+ init.body = JSON.stringify(body);
25
+ }
26
+ const res = await fetch(url, init);
27
+ if (!res.ok) {
28
+ const err = await res.json().catch(() => ({ error: res.statusText }));
29
+ throw new Error(err.error || `API error: ${res.status}`);
30
+ }
31
+ return res.json();
32
+ }
33
+ async listAccounts() {
34
+ const data = await this.request('GET', '/accounts');
35
+ return data.accounts;
36
+ }
37
+ async listPosts(params) {
38
+ const searchParams = new URLSearchParams();
39
+ if (params?.status)
40
+ searchParams.set('status', params.status);
41
+ if (params?.platform)
42
+ searchParams.set('platform', params.platform);
43
+ if (params?.limit)
44
+ searchParams.set('limit', String(params.limit));
45
+ if (params?.offset)
46
+ searchParams.set('offset', String(params.offset));
47
+ const qs = searchParams.toString();
48
+ return this.request('GET', `/posts${qs ? `?${qs}` : ''}`);
49
+ }
50
+ async createPost(data) {
51
+ return this.request('POST', '/posts', data);
52
+ }
53
+ async findAvailableSlots(params) {
54
+ const searchParams = new URLSearchParams();
55
+ if (params?.account_ids?.length)
56
+ searchParams.set('account_ids', params.account_ids.join(','));
57
+ if (params?.timezone)
58
+ searchParams.set('timezone', params.timezone);
59
+ if (params?.count)
60
+ searchParams.set('count', String(params.count));
61
+ const qs = searchParams.toString();
62
+ const data = await this.request('GET', `/slots/next${qs ? `?${qs}` : ''}`);
63
+ return data.slots;
64
+ }
65
+ async uploadMedia(input) {
66
+ let data;
67
+ if (input.filePath) {
68
+ const buffer = await readFile(resolve(input.filePath));
69
+ data = buffer.toString('base64');
70
+ }
71
+ else if (input.base64Data) {
72
+ data = input.base64Data;
73
+ }
74
+ else {
75
+ throw new Error('Provide filePath or base64Data');
76
+ }
77
+ const contentType = input.contentType ||
78
+ guessContentType(input.filename);
79
+ return this.request('POST', '/media/upload', {
80
+ filename: input.filename,
81
+ data,
82
+ content_type: contentType,
83
+ });
84
+ }
85
+ }
86
+ function guessContentType(filename) {
87
+ const ext = filename.split('.').pop()?.toLowerCase();
88
+ const map = {
89
+ jpg: 'image/jpeg',
90
+ jpeg: 'image/jpeg',
91
+ png: 'image/png',
92
+ gif: 'image/gif',
93
+ webp: 'image/webp',
94
+ mp4: 'video/mp4',
95
+ mov: 'video/quicktime',
96
+ webm: 'video/webm',
97
+ };
98
+ return map[ext || ''] || 'application/octet-stream';
99
+ }
@@ -0,0 +1,44 @@
1
+ import { z } from 'zod';
2
+ import type { PosterlyClient } from '../lib/api-client.js';
3
+ export declare const createPostTool: {
4
+ name: string;
5
+ description: string;
6
+ inputSchema: z.ZodObject<{
7
+ account_id: z.ZodOptional<z.ZodString>;
8
+ username: z.ZodOptional<z.ZodString>;
9
+ platform: z.ZodOptional<z.ZodString>;
10
+ caption: z.ZodString;
11
+ scheduled_at: z.ZodOptional<z.ZodString>;
12
+ media_url: z.ZodOptional<z.ZodString>;
13
+ media_urls: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
14
+ post_type: z.ZodOptional<z.ZodString>;
15
+ }, "strip", z.ZodTypeAny, {
16
+ caption: string;
17
+ platform?: string | undefined;
18
+ account_id?: string | undefined;
19
+ username?: string | undefined;
20
+ scheduled_at?: string | undefined;
21
+ media_url?: string | undefined;
22
+ media_urls?: string[] | undefined;
23
+ post_type?: string | undefined;
24
+ }, {
25
+ caption: string;
26
+ platform?: string | undefined;
27
+ account_id?: string | undefined;
28
+ username?: string | undefined;
29
+ scheduled_at?: string | undefined;
30
+ media_url?: string | undefined;
31
+ media_urls?: string[] | undefined;
32
+ post_type?: string | undefined;
33
+ }>;
34
+ execute(client: PosterlyClient, input: {
35
+ account_id?: string;
36
+ username?: string;
37
+ platform?: string;
38
+ caption: string;
39
+ scheduled_at?: string;
40
+ media_url?: string;
41
+ media_urls?: string[];
42
+ post_type?: string;
43
+ }): Promise<string>;
44
+ };
@@ -0,0 +1,35 @@
1
+ import { z } from 'zod';
2
+ export const createPostTool = {
3
+ name: 'create_post',
4
+ description: 'Schedule or immediately publish a social media post. Provide either account_id OR username+platform to identify the account. If scheduled_at is omitted, the post publishes immediately.',
5
+ inputSchema: z.object({
6
+ account_id: z.string().optional().describe('Social account ID (from list_accounts)'),
7
+ username: z.string().optional().describe('Account username (alternative to account_id)'),
8
+ platform: z
9
+ .string()
10
+ .optional()
11
+ .describe('Platform name (required with username): instagram, twitter, linkedin, facebook, tiktok, threads, youtube, pinterest, google_business'),
12
+ caption: z.string().describe('The post caption/text content'),
13
+ scheduled_at: z
14
+ .string()
15
+ .optional()
16
+ .describe('ISO 8601 datetime for scheduling (e.g. 2026-03-05T09:00:00Z). Omit for immediate publish.'),
17
+ media_url: z.string().optional().describe('URL of media to attach (image or video)'),
18
+ media_urls: z
19
+ .array(z.string())
20
+ .optional()
21
+ .describe('Multiple media URLs for carousel posts'),
22
+ post_type: z
23
+ .string()
24
+ .optional()
25
+ .describe('Post type: text, image, video, carousel, reel, story'),
26
+ }),
27
+ async execute(client, input) {
28
+ const result = await client.createPost(input);
29
+ const p = result.post;
30
+ const when = p.scheduled_at
31
+ ? new Date(p.scheduled_at).toLocaleString()
32
+ : 'now';
33
+ return `Post created successfully!\n• ID: ${p.id}\n• Platform: ${p.platform || input.platform || 'unknown'}\n• Type: ${p.post_type}\n• Status: ${p.status}\n• Scheduled: ${when}`;
34
+ },
35
+ };
@@ -0,0 +1,24 @@
1
+ import { z } from 'zod';
2
+ import type { PosterlyClient } from '../lib/api-client.js';
3
+ export declare const findSlotTool: {
4
+ name: string;
5
+ description: string;
6
+ inputSchema: z.ZodObject<{
7
+ account_ids: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
8
+ timezone: z.ZodOptional<z.ZodString>;
9
+ count: z.ZodOptional<z.ZodNumber>;
10
+ }, "strip", z.ZodTypeAny, {
11
+ account_ids?: string[] | undefined;
12
+ timezone?: string | undefined;
13
+ count?: number | undefined;
14
+ }, {
15
+ account_ids?: string[] | undefined;
16
+ timezone?: string | undefined;
17
+ count?: number | undefined;
18
+ }>;
19
+ execute(client: PosterlyClient, input: {
20
+ account_ids?: string[];
21
+ timezone?: string;
22
+ count?: number;
23
+ }): Promise<string>;
24
+ };
@@ -0,0 +1,29 @@
1
+ import { z } from 'zod';
2
+ export const findSlotTool = {
3
+ name: 'find_available_slot',
4
+ description: 'Find available time slots for posting. Respects a 1-hour gap between posts and preferred hours (8am–10pm). Returns up to 10 slots.',
5
+ inputSchema: z.object({
6
+ account_ids: z
7
+ .array(z.string())
8
+ .optional()
9
+ .describe('Filter slots for specific account IDs'),
10
+ timezone: z
11
+ .string()
12
+ .optional()
13
+ .describe('IANA timezone (e.g. America/New_York). Defaults to America/New_York.'),
14
+ count: z
15
+ .number()
16
+ .min(1)
17
+ .max(10)
18
+ .optional()
19
+ .describe('Number of slots to return (default 5, max 10)'),
20
+ }),
21
+ async execute(client, input) {
22
+ const slots = await client.findAvailableSlots(input);
23
+ if (slots.length === 0) {
24
+ return 'No available slots found in the next 14 days.';
25
+ }
26
+ const lines = slots.map((s, i) => `${i + 1}. ${s.local_time} — ${new Date(s.time).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}`);
27
+ return `Available posting slots:\n${lines.join('\n')}`;
28
+ },
29
+ };
@@ -0,0 +1,8 @@
1
+ import { z } from 'zod';
2
+ import type { PosterlyClient } from '../lib/api-client.js';
3
+ export declare const listAccountsTool: {
4
+ name: string;
5
+ description: string;
6
+ inputSchema: z.ZodObject<{}, "strip", z.ZodTypeAny, {}, {}>;
7
+ execute(client: PosterlyClient): Promise<string>;
8
+ };
@@ -0,0 +1,14 @@
1
+ import { z } from 'zod';
2
+ export const listAccountsTool = {
3
+ name: 'list_accounts',
4
+ description: 'List all connected social media accounts. Shows platform, username, and account ID for each.',
5
+ inputSchema: z.object({}),
6
+ async execute(client) {
7
+ const accounts = await client.listAccounts();
8
+ if (accounts.length === 0) {
9
+ return 'No connected accounts found. Connect accounts in the posterly dashboard first.';
10
+ }
11
+ const lines = accounts.map((a) => `• ${a.platform} — @${a.username} (ID: ${a.id})`);
12
+ return `Connected accounts (${accounts.length}):\n${lines.join('\n')}`;
13
+ },
14
+ };
@@ -0,0 +1,24 @@
1
+ import { z } from 'zod';
2
+ import type { PosterlyClient } from '../lib/api-client.js';
3
+ export declare const listPostsTool: {
4
+ name: string;
5
+ description: string;
6
+ inputSchema: z.ZodObject<{
7
+ status: z.ZodOptional<z.ZodString>;
8
+ platform: z.ZodOptional<z.ZodString>;
9
+ limit: z.ZodOptional<z.ZodNumber>;
10
+ }, "strip", z.ZodTypeAny, {
11
+ status?: string | undefined;
12
+ platform?: string | undefined;
13
+ limit?: number | undefined;
14
+ }, {
15
+ status?: string | undefined;
16
+ platform?: string | undefined;
17
+ limit?: number | undefined;
18
+ }>;
19
+ execute(client: PosterlyClient, input: {
20
+ status?: string;
21
+ platform?: string;
22
+ limit?: number;
23
+ }): Promise<string>;
24
+ };
@@ -0,0 +1,36 @@
1
+ import { z } from 'zod';
2
+ export const listPostsTool = {
3
+ name: 'list_posts',
4
+ description: 'List upcoming or recent posts. Filter by status (scheduled, published, failed, draft) or platform.',
5
+ inputSchema: z.object({
6
+ status: z
7
+ .string()
8
+ .optional()
9
+ .describe('Filter by status: draft, scheduled, published, failed'),
10
+ platform: z
11
+ .string()
12
+ .optional()
13
+ .describe('Filter by platform: instagram, twitter, linkedin, etc.'),
14
+ limit: z
15
+ .number()
16
+ .min(1)
17
+ .max(50)
18
+ .optional()
19
+ .describe('Number of posts to return (default 10, max 50)'),
20
+ }),
21
+ async execute(client, input) {
22
+ const { posts, total } = await client.listPosts({
23
+ ...input,
24
+ limit: input.limit || 10,
25
+ });
26
+ if (posts.length === 0) {
27
+ return 'No posts found matching your criteria.';
28
+ }
29
+ const lines = posts.map((p) => {
30
+ const date = new Date(p.scheduled_at).toLocaleString();
31
+ const caption = p.content.length > 60 ? p.content.slice(0, 60) + '…' : p.content;
32
+ return `• [${p.status}] #${p.id} — ${caption} (${date})`;
33
+ });
34
+ return `Posts (${posts.length} of ${total}):\n${lines.join('\n')}`;
35
+ },
36
+ };
@@ -0,0 +1,28 @@
1
+ import { z } from 'zod';
2
+ import type { PosterlyClient } from '../lib/api-client.js';
3
+ export declare const uploadMediaTool: {
4
+ name: string;
5
+ description: string;
6
+ inputSchema: z.ZodObject<{
7
+ file_path: z.ZodOptional<z.ZodString>;
8
+ base64_data: z.ZodOptional<z.ZodString>;
9
+ filename: z.ZodString;
10
+ content_type: z.ZodOptional<z.ZodString>;
11
+ }, "strip", z.ZodTypeAny, {
12
+ filename: string;
13
+ file_path?: string | undefined;
14
+ base64_data?: string | undefined;
15
+ content_type?: string | undefined;
16
+ }, {
17
+ filename: string;
18
+ file_path?: string | undefined;
19
+ base64_data?: string | undefined;
20
+ content_type?: string | undefined;
21
+ }>;
22
+ execute(client: PosterlyClient, input: {
23
+ file_path?: string;
24
+ base64_data?: string;
25
+ filename: string;
26
+ content_type?: string;
27
+ }): Promise<string>;
28
+ };
@@ -0,0 +1,34 @@
1
+ import { z } from 'zod';
2
+ export const uploadMediaTool = {
3
+ name: 'upload_media',
4
+ description: 'Upload an image or video file to posterly. Returns a URL that can be used with create_post. Supports JPEG, PNG, GIF, WebP, MP4, MOV, WebM. Max 5MB.',
5
+ inputSchema: z.object({
6
+ file_path: z
7
+ .string()
8
+ .optional()
9
+ .describe('Absolute path to a local file to upload'),
10
+ base64_data: z
11
+ .string()
12
+ .optional()
13
+ .describe('Base64-encoded file data (alternative to file_path)'),
14
+ filename: z
15
+ .string()
16
+ .describe('Filename with extension (e.g. photo.jpg, video.mp4)'),
17
+ content_type: z
18
+ .string()
19
+ .optional()
20
+ .describe('MIME type (auto-detected from filename if omitted)'),
21
+ }),
22
+ async execute(client, input) {
23
+ if (!input.file_path && !input.base64_data) {
24
+ return 'Error: Provide either file_path or base64_data.';
25
+ }
26
+ const result = await client.uploadMedia({
27
+ filePath: input.file_path,
28
+ base64Data: input.base64_data,
29
+ filename: input.filename,
30
+ contentType: input.content_type,
31
+ });
32
+ return `Media uploaded successfully!\n• URL: ${result.url}\n\nUse this URL as media_url when creating a post.`;
33
+ },
34
+ };
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "posterly-mcp-server",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for posterly — schedule social media posts from Claude Desktop",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "posterly-mcp": "./dist/index.js"
9
+ },
10
+ "main": "./dist/index.js",
11
+ "scripts": {
12
+ "build": "tsc",
13
+ "dev": "tsc --watch",
14
+ "start": "node dist/index.js"
15
+ },
16
+ "dependencies": {
17
+ "@modelcontextprotocol/sdk": "^1.12.1",
18
+ "zod": "^3.24.0"
19
+ },
20
+ "devDependencies": {
21
+ "typescript": "^5.7.0",
22
+ "@types/node": "^22.0.0"
23
+ },
24
+ "engines": {
25
+ "node": ">=20"
26
+ }
27
+ }
package/src/index.ts ADDED
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
+ import { PosterlyClient } from './lib/api-client.js';
6
+ import { listAccountsTool } from './tools/list-accounts.js';
7
+ import { createPostTool } from './tools/create-post.js';
8
+ import { findSlotTool } from './tools/find-slot.js';
9
+ import { listPostsTool } from './tools/list-posts.js';
10
+ import { uploadMediaTool } from './tools/upload-media.js';
11
+
12
+ const server = new McpServer({
13
+ name: 'posterly',
14
+ version: '0.1.0',
15
+ });
16
+
17
+ let client: PosterlyClient;
18
+
19
+ try {
20
+ client = new PosterlyClient();
21
+ } catch (err: any) {
22
+ console.error(err.message);
23
+ process.exit(1);
24
+ }
25
+
26
+ // Register tools
27
+ server.tool(
28
+ listAccountsTool.name,
29
+ listAccountsTool.description,
30
+ listAccountsTool.inputSchema.shape,
31
+ async () => {
32
+ try {
33
+ const text = await listAccountsTool.execute(client);
34
+ return { content: [{ type: 'text' as const, text }] };
35
+ } catch (err: any) {
36
+ return { content: [{ type: 'text' as const, text: `Error: ${err.message}` }], isError: true };
37
+ }
38
+ }
39
+ );
40
+
41
+ server.tool(
42
+ createPostTool.name,
43
+ createPostTool.description,
44
+ createPostTool.inputSchema.shape,
45
+ async (input) => {
46
+ try {
47
+ const text = await createPostTool.execute(client, input as any);
48
+ return { content: [{ type: 'text' as const, text }] };
49
+ } catch (err: any) {
50
+ return { content: [{ type: 'text' as const, text: `Error: ${err.message}` }], isError: true };
51
+ }
52
+ }
53
+ );
54
+
55
+ server.tool(
56
+ findSlotTool.name,
57
+ findSlotTool.description,
58
+ findSlotTool.inputSchema.shape,
59
+ async (input) => {
60
+ try {
61
+ const text = await findSlotTool.execute(client, input as any);
62
+ return { content: [{ type: 'text' as const, text }] };
63
+ } catch (err: any) {
64
+ return { content: [{ type: 'text' as const, text: `Error: ${err.message}` }], isError: true };
65
+ }
66
+ }
67
+ );
68
+
69
+ server.tool(
70
+ listPostsTool.name,
71
+ listPostsTool.description,
72
+ listPostsTool.inputSchema.shape,
73
+ async (input) => {
74
+ try {
75
+ const text = await listPostsTool.execute(client, input as any);
76
+ return { content: [{ type: 'text' as const, text }] };
77
+ } catch (err: any) {
78
+ return { content: [{ type: 'text' as const, text: `Error: ${err.message}` }], isError: true };
79
+ }
80
+ }
81
+ );
82
+
83
+ server.tool(
84
+ uploadMediaTool.name,
85
+ uploadMediaTool.description,
86
+ uploadMediaTool.inputSchema.shape,
87
+ async (input) => {
88
+ try {
89
+ const text = await uploadMediaTool.execute(client, input as any);
90
+ return { content: [{ type: 'text' as const, text }] };
91
+ } catch (err: any) {
92
+ return { content: [{ type: 'text' as const, text: `Error: ${err.message}` }], isError: true };
93
+ }
94
+ }
95
+ );
96
+
97
+ // Start the server
98
+ async function main() {
99
+ const transport = new StdioServerTransport();
100
+ await server.connect(transport);
101
+ }
102
+
103
+ main().catch((err) => {
104
+ console.error('Fatal error:', err);
105
+ process.exit(1);
106
+ });
@@ -0,0 +1,164 @@
1
+ import { readFile } from 'fs/promises';
2
+ import { resolve } from 'path';
3
+
4
+ export interface Account {
5
+ id: string;
6
+ platform: string;
7
+ username: string;
8
+ account_type?: string;
9
+ profile_picture_url?: string;
10
+ }
11
+
12
+ export interface Post {
13
+ id: number;
14
+ content: string;
15
+ scheduled_at: string;
16
+ social_account_id: string;
17
+ media_url?: string;
18
+ media_urls?: string[];
19
+ status: string;
20
+ post_type?: string;
21
+ platform_post_url?: string;
22
+ published_at?: string;
23
+ }
24
+
25
+ export interface Slot {
26
+ time: string;
27
+ local_time: string;
28
+ }
29
+
30
+ export class PosterlyClient {
31
+ private baseUrl: string;
32
+ private apiKey: string;
33
+
34
+ constructor(apiKey?: string, baseUrl?: string) {
35
+ this.apiKey = apiKey || process.env.POSTERLY_API_KEY || '';
36
+ this.baseUrl = (baseUrl || process.env.POSTERLY_URL || 'https://posterly.app').replace(/\/$/, '');
37
+
38
+ if (!this.apiKey) {
39
+ throw new Error('POSTERLY_API_KEY is required. Set it as an environment variable.');
40
+ }
41
+ }
42
+
43
+ private async request<T>(
44
+ method: string,
45
+ path: string,
46
+ body?: unknown,
47
+ ): Promise<T> {
48
+ const url = `${this.baseUrl}/api/v1${path}`;
49
+ const headers: Record<string, string> = {
50
+ Authorization: `Bearer ${this.apiKey}`,
51
+ };
52
+
53
+ const init: RequestInit = { method, headers };
54
+
55
+ if (body instanceof FormData) {
56
+ init.body = body;
57
+ } else if (body) {
58
+ headers['Content-Type'] = 'application/json';
59
+ init.body = JSON.stringify(body);
60
+ }
61
+
62
+ const res = await fetch(url, init);
63
+
64
+ if (!res.ok) {
65
+ const err = await res.json().catch(() => ({ error: res.statusText }));
66
+ throw new Error(err.error || `API error: ${res.status}`);
67
+ }
68
+
69
+ return res.json() as Promise<T>;
70
+ }
71
+
72
+ async listAccounts(): Promise<Account[]> {
73
+ const data = await this.request<{ accounts: Account[] }>('GET', '/accounts');
74
+ return data.accounts;
75
+ }
76
+
77
+ async listPosts(params?: {
78
+ status?: string;
79
+ platform?: string;
80
+ limit?: number;
81
+ offset?: number;
82
+ }): Promise<{ posts: Post[]; total: number }> {
83
+ const searchParams = new URLSearchParams();
84
+ if (params?.status) searchParams.set('status', params.status);
85
+ if (params?.platform) searchParams.set('platform', params.platform);
86
+ if (params?.limit) searchParams.set('limit', String(params.limit));
87
+ if (params?.offset) searchParams.set('offset', String(params.offset));
88
+
89
+ const qs = searchParams.toString();
90
+ return this.request('GET', `/posts${qs ? `?${qs}` : ''}`);
91
+ }
92
+
93
+ async createPost(data: {
94
+ account_id?: string;
95
+ username?: string;
96
+ platform?: string;
97
+ caption: string;
98
+ scheduled_at?: string;
99
+ post_type?: string;
100
+ media_url?: string;
101
+ media_urls?: string[];
102
+ metadata?: Record<string, unknown>;
103
+ }): Promise<{ post: Post }> {
104
+ return this.request('POST', '/posts', data);
105
+ }
106
+
107
+ async findAvailableSlots(params?: {
108
+ account_ids?: string[];
109
+ timezone?: string;
110
+ count?: number;
111
+ }): Promise<Slot[]> {
112
+ const searchParams = new URLSearchParams();
113
+ if (params?.account_ids?.length) searchParams.set('account_ids', params.account_ids.join(','));
114
+ if (params?.timezone) searchParams.set('timezone', params.timezone);
115
+ if (params?.count) searchParams.set('count', String(params.count));
116
+
117
+ const qs = searchParams.toString();
118
+ const data = await this.request<{ slots: Slot[] }>('GET', `/slots/next${qs ? `?${qs}` : ''}`);
119
+ return data.slots;
120
+ }
121
+
122
+ async uploadMedia(input: {
123
+ filePath?: string;
124
+ base64Data?: string;
125
+ filename: string;
126
+ contentType?: string;
127
+ }): Promise<{ url: string; path: string }> {
128
+ let data: string;
129
+
130
+ if (input.filePath) {
131
+ const buffer = await readFile(resolve(input.filePath));
132
+ data = buffer.toString('base64');
133
+ } else if (input.base64Data) {
134
+ data = input.base64Data;
135
+ } else {
136
+ throw new Error('Provide filePath or base64Data');
137
+ }
138
+
139
+ const contentType =
140
+ input.contentType ||
141
+ guessContentType(input.filename);
142
+
143
+ return this.request('POST', '/media/upload', {
144
+ filename: input.filename,
145
+ data,
146
+ content_type: contentType,
147
+ });
148
+ }
149
+ }
150
+
151
+ function guessContentType(filename: string): string {
152
+ const ext = filename.split('.').pop()?.toLowerCase();
153
+ const map: Record<string, string> = {
154
+ jpg: 'image/jpeg',
155
+ jpeg: 'image/jpeg',
156
+ png: 'image/png',
157
+ gif: 'image/gif',
158
+ webp: 'image/webp',
159
+ mp4: 'video/mp4',
160
+ mov: 'video/quicktime',
161
+ webm: 'video/webm',
162
+ };
163
+ return map[ext || ''] || 'application/octet-stream';
164
+ }
@@ -0,0 +1,53 @@
1
+ import { z } from 'zod';
2
+ import type { PosterlyClient } from '../lib/api-client.js';
3
+
4
+ export const createPostTool = {
5
+ name: 'create_post',
6
+ description:
7
+ 'Schedule or immediately publish a social media post. Provide either account_id OR username+platform to identify the account. If scheduled_at is omitted, the post publishes immediately.',
8
+ inputSchema: z.object({
9
+ account_id: z.string().optional().describe('Social account ID (from list_accounts)'),
10
+ username: z.string().optional().describe('Account username (alternative to account_id)'),
11
+ platform: z
12
+ .string()
13
+ .optional()
14
+ .describe('Platform name (required with username): instagram, twitter, linkedin, facebook, tiktok, threads, youtube, pinterest, google_business'),
15
+ caption: z.string().describe('The post caption/text content'),
16
+ scheduled_at: z
17
+ .string()
18
+ .optional()
19
+ .describe('ISO 8601 datetime for scheduling (e.g. 2026-03-05T09:00:00Z). Omit for immediate publish.'),
20
+ media_url: z.string().optional().describe('URL of media to attach (image or video)'),
21
+ media_urls: z
22
+ .array(z.string())
23
+ .optional()
24
+ .describe('Multiple media URLs for carousel posts'),
25
+ post_type: z
26
+ .string()
27
+ .optional()
28
+ .describe('Post type: text, image, video, carousel, reel, story'),
29
+ }),
30
+
31
+ async execute(
32
+ client: PosterlyClient,
33
+ input: {
34
+ account_id?: string;
35
+ username?: string;
36
+ platform?: string;
37
+ caption: string;
38
+ scheduled_at?: string;
39
+ media_url?: string;
40
+ media_urls?: string[];
41
+ post_type?: string;
42
+ }
43
+ ) {
44
+ const result = await client.createPost(input);
45
+ const p = result.post as Record<string, any>;
46
+
47
+ const when = p.scheduled_at
48
+ ? new Date(p.scheduled_at).toLocaleString()
49
+ : 'now';
50
+
51
+ return `Post created successfully!\n• ID: ${p.id}\n• Platform: ${p.platform || input.platform || 'unknown'}\n• Type: ${p.post_type}\n• Status: ${p.status}\n• Scheduled: ${when}`;
52
+ },
53
+ };
@@ -0,0 +1,44 @@
1
+ import { z } from 'zod';
2
+ import type { PosterlyClient } from '../lib/api-client.js';
3
+
4
+ export const findSlotTool = {
5
+ name: 'find_available_slot',
6
+ description:
7
+ 'Find available time slots for posting. Respects a 1-hour gap between posts and preferred hours (8am–10pm). Returns up to 10 slots.',
8
+ inputSchema: z.object({
9
+ account_ids: z
10
+ .array(z.string())
11
+ .optional()
12
+ .describe('Filter slots for specific account IDs'),
13
+ timezone: z
14
+ .string()
15
+ .optional()
16
+ .describe('IANA timezone (e.g. America/New_York). Defaults to America/New_York.'),
17
+ count: z
18
+ .number()
19
+ .min(1)
20
+ .max(10)
21
+ .optional()
22
+ .describe('Number of slots to return (default 5, max 10)'),
23
+ }),
24
+
25
+ async execute(
26
+ client: PosterlyClient,
27
+ input: {
28
+ account_ids?: string[];
29
+ timezone?: string;
30
+ count?: number;
31
+ }
32
+ ) {
33
+ const slots = await client.findAvailableSlots(input);
34
+
35
+ if (slots.length === 0) {
36
+ return 'No available slots found in the next 14 days.';
37
+ }
38
+
39
+ const lines = slots.map(
40
+ (s, i) => `${i + 1}. ${s.local_time} — ${new Date(s.time).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}`
41
+ );
42
+ return `Available posting slots:\n${lines.join('\n')}`;
43
+ },
44
+ };
@@ -0,0 +1,21 @@
1
+ import { z } from 'zod';
2
+ import type { PosterlyClient } from '../lib/api-client.js';
3
+
4
+ export const listAccountsTool = {
5
+ name: 'list_accounts',
6
+ description: 'List all connected social media accounts. Shows platform, username, and account ID for each.',
7
+ inputSchema: z.object({}),
8
+
9
+ async execute(client: PosterlyClient) {
10
+ const accounts = await client.listAccounts();
11
+
12
+ if (accounts.length === 0) {
13
+ return 'No connected accounts found. Connect accounts in the posterly dashboard first.';
14
+ }
15
+
16
+ const lines = accounts.map(
17
+ (a) => `• ${a.platform} — @${a.username} (ID: ${a.id})`
18
+ );
19
+ return `Connected accounts (${accounts.length}):\n${lines.join('\n')}`;
20
+ },
21
+ };
@@ -0,0 +1,47 @@
1
+ import { z } from 'zod';
2
+ import type { PosterlyClient } from '../lib/api-client.js';
3
+
4
+ export const listPostsTool = {
5
+ name: 'list_posts',
6
+ description:
7
+ 'List upcoming or recent posts. Filter by status (scheduled, published, failed, draft) or platform.',
8
+ inputSchema: z.object({
9
+ status: z
10
+ .string()
11
+ .optional()
12
+ .describe('Filter by status: draft, scheduled, published, failed'),
13
+ platform: z
14
+ .string()
15
+ .optional()
16
+ .describe('Filter by platform: instagram, twitter, linkedin, etc.'),
17
+ limit: z
18
+ .number()
19
+ .min(1)
20
+ .max(50)
21
+ .optional()
22
+ .describe('Number of posts to return (default 10, max 50)'),
23
+ }),
24
+
25
+ async execute(
26
+ client: PosterlyClient,
27
+ input: { status?: string; platform?: string; limit?: number }
28
+ ) {
29
+ const { posts, total } = await client.listPosts({
30
+ ...input,
31
+ limit: input.limit || 10,
32
+ });
33
+
34
+ if (posts.length === 0) {
35
+ return 'No posts found matching your criteria.';
36
+ }
37
+
38
+ const lines = posts.map((p) => {
39
+ const date = new Date(p.scheduled_at).toLocaleString();
40
+ const caption =
41
+ p.content.length > 60 ? p.content.slice(0, 60) + '…' : p.content;
42
+ return `• [${p.status}] #${p.id} — ${caption} (${date})`;
43
+ });
44
+
45
+ return `Posts (${posts.length} of ${total}):\n${lines.join('\n')}`;
46
+ },
47
+ };
@@ -0,0 +1,48 @@
1
+ import { z } from 'zod';
2
+ import type { PosterlyClient } from '../lib/api-client.js';
3
+
4
+ export const uploadMediaTool = {
5
+ name: 'upload_media',
6
+ description:
7
+ 'Upload an image or video file to posterly. Returns a URL that can be used with create_post. Supports JPEG, PNG, GIF, WebP, MP4, MOV, WebM. Max 5MB.',
8
+ inputSchema: z.object({
9
+ file_path: z
10
+ .string()
11
+ .optional()
12
+ .describe('Absolute path to a local file to upload'),
13
+ base64_data: z
14
+ .string()
15
+ .optional()
16
+ .describe('Base64-encoded file data (alternative to file_path)'),
17
+ filename: z
18
+ .string()
19
+ .describe('Filename with extension (e.g. photo.jpg, video.mp4)'),
20
+ content_type: z
21
+ .string()
22
+ .optional()
23
+ .describe('MIME type (auto-detected from filename if omitted)'),
24
+ }),
25
+
26
+ async execute(
27
+ client: PosterlyClient,
28
+ input: {
29
+ file_path?: string;
30
+ base64_data?: string;
31
+ filename: string;
32
+ content_type?: string;
33
+ }
34
+ ) {
35
+ if (!input.file_path && !input.base64_data) {
36
+ return 'Error: Provide either file_path or base64_data.';
37
+ }
38
+
39
+ const result = await client.uploadMedia({
40
+ filePath: input.file_path,
41
+ base64Data: input.base64_data,
42
+ filename: input.filename,
43
+ contentType: input.content_type,
44
+ });
45
+
46
+ return `Media uploaded successfully!\n• URL: ${result.url}\n\nUse this URL as media_url when creating a post.`;
47
+ },
48
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "declaration": true
12
+ },
13
+ "include": ["src/**/*"]
14
+ }