posterly-mcp-server 0.5.0 → 0.7.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.
package/README.md ADDED
@@ -0,0 +1,178 @@
1
+ # posterly MCP Server
2
+
3
+ Use Posterly from any MCP-compatible AI client.
4
+
5
+ This package gives Claude Desktop, Cursor, Windsurf, Cline, and other local MCP clients a `stdio` server that can:
6
+
7
+ - list connected social accounts
8
+ - resolve brands/clients into the right accounts
9
+ - schedule and manage posts
10
+ - upload media
11
+ - generate images
12
+ - read account and post analytics
13
+
14
+ Posterly also exposes the same toolset over HTTP at [poster.ly/mcp](https://www.poster.ly/mcp), but this npm package is the local `stdio` transport.
15
+
16
+ ## Requirements
17
+
18
+ - Node.js `20+`
19
+ - A Posterly account: [poster.ly/signup](https://www.poster.ly/signup)
20
+ - The Posterly API add-on enabled: [poster.ly/dashboard/api](https://www.poster.ly/dashboard/api)
21
+ - A Posterly API key
22
+
23
+ ## Install
24
+
25
+ You can install globally:
26
+
27
+ ```bash
28
+ npm install -g posterly-mcp-server
29
+ ```
30
+
31
+ Or just use it via `npx` in your MCP config:
32
+
33
+ ```json
34
+ {
35
+ "mcpServers": {
36
+ "posterly": {
37
+ "command": "npx",
38
+ "args": ["-y", "posterly-mcp-server"],
39
+ "env": {
40
+ "POSTERLY_API_KEY": "pst_live_your_key_here"
41
+ }
42
+ }
43
+ }
44
+ }
45
+ ```
46
+
47
+ ## Quick setup
48
+
49
+ 1. Sign up at [poster.ly](https://www.poster.ly/signup)
50
+ 2. Go to [Dashboard → API & MCP](https://www.poster.ly/dashboard/api)
51
+ 3. Enable the API add-on
52
+ 4. Generate an API key
53
+ 5. Paste it into your MCP client config as `POSTERLY_API_KEY`
54
+ 6. Restart your AI client
55
+
56
+ ## Example configs
57
+
58
+ ### Claude Desktop
59
+
60
+ Add this to your Claude Desktop MCP config:
61
+
62
+ ```json
63
+ {
64
+ "mcpServers": {
65
+ "posterly": {
66
+ "command": "npx",
67
+ "args": ["-y", "posterly-mcp-server"],
68
+ "env": {
69
+ "POSTERLY_API_KEY": "pst_live_your_key_here"
70
+ }
71
+ }
72
+ }
73
+ }
74
+ ```
75
+
76
+ ### Cursor
77
+
78
+ Add the same server definition to your Cursor MCP settings:
79
+
80
+ ```json
81
+ {
82
+ "mcpServers": {
83
+ "posterly": {
84
+ "command": "npx",
85
+ "args": ["-y", "posterly-mcp-server"],
86
+ "env": {
87
+ "POSTERLY_API_KEY": "pst_live_your_key_here"
88
+ }
89
+ }
90
+ }
91
+ }
92
+ ```
93
+
94
+ ## Available tools
95
+
96
+ `posterly-mcp-server@0.7.0` exposes 16 tools:
97
+
98
+ - `whoami`
99
+ - `list_accounts`
100
+ - `list_brands`
101
+ - `get_brand`
102
+ - `list_brand_accounts`
103
+ - `get_brand_profile`
104
+ - `list_posts`
105
+ - `get_post`
106
+ - `create_post`
107
+ - `update_post`
108
+ - `delete_post`
109
+ - `upload_media`
110
+ - `find_available_slot`
111
+ - `generate_image`
112
+ - `get_account_analytics`
113
+ - `get_post_analytics`
114
+
115
+ ## What the brand tools are for
116
+
117
+ Posterly workspaces often have multiple connected accounts under one client or brand.
118
+
119
+ The brand tools let an assistant work at the same level a human does:
120
+
121
+ - `list_brands` lets the agent see clients/brands in the workspace
122
+ - `get_brand` returns summary info for one brand
123
+ - `list_brand_accounts` resolves a brand into the actual connected accounts
124
+ - `get_brand_profile` returns saved brand guidance like tone, audience, keywords, dos and don'ts, and visual notes
125
+
126
+ This makes prompts like:
127
+
128
+ - "How is Grassroots doing on Instagram?"
129
+ - "Write a post for the Posterly brand voice"
130
+ - "Schedule something for our Dubai dental client"
131
+
132
+ much more reliable than forcing the agent to guess from raw account handles alone.
133
+
134
+ ## Example prompts
135
+
136
+ - `What Posterly accounts do I have connected?`
137
+ - `List my brands in Posterly`
138
+ - `Show me the brand profile for Grassroots`
139
+ - `Find the next 3 posting slots for my LinkedIn account`
140
+ - `Schedule a post for tomorrow at 9am for the Posterly Instagram account`
141
+ - `How did Grassroots perform on Instagram in the last 30 days?`
142
+
143
+ ## Pricing
144
+
145
+ This package uses the Posterly API/MCP add-on:
146
+
147
+ - `$3/month` add-on
148
+ - `30 requests/hour` per API key
149
+ - user-created API keys per plan: Starter 1, Pro 2, Power User 3, Agency 4
150
+ - works across all 11 supported platforms
151
+
152
+ Each API call counts as one request, so you can still schedule multiple posts in a single request to maximize throughput.
153
+
154
+ Details: [poster.ly/dashboard/api](https://www.poster.ly/dashboard/api)
155
+
156
+ ## Links
157
+
158
+ - Docs: [poster.ly/mcp](https://www.poster.ly/mcp)
159
+ - OpenClaw skill: [poster.ly/openclaw](https://www.poster.ly/openclaw)
160
+ - API add-on: [poster.ly/dashboard/api](https://www.poster.ly/dashboard/api)
161
+ - MCP server card: [/.well-known/mcp/server-card.json](https://www.poster.ly/.well-known/mcp/server-card.json)
162
+ - OAuth authorization server metadata: [/.well-known/oauth-authorization-server](https://www.poster.ly/.well-known/oauth-authorization-server)
163
+ - OAuth protected resource metadata: [/.well-known/oauth-protected-resource](https://www.poster.ly/.well-known/oauth-protected-resource)
164
+
165
+ ## Development
166
+
167
+ From the `mcp-server` directory:
168
+
169
+ ```bash
170
+ npm install
171
+ npm run build
172
+ npm start
173
+ ```
174
+
175
+ The package reads:
176
+
177
+ - `POSTERLY_API_KEY`
178
+ - optional `POSTERLY_URL` if you need to point at a non-production environment
package/dist/index.js CHANGED
@@ -3,6 +3,10 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
4
  import { PosterlyClient } from './lib/api-client.js';
5
5
  import { listAccountsTool } from './tools/list-accounts.js';
6
+ import { listBrandsTool } from './tools/list-brands.js';
7
+ import { getBrandTool } from './tools/get-brand.js';
8
+ import { listBrandAccountsTool } from './tools/list-brand-accounts.js';
9
+ import { getBrandProfileTool } from './tools/get-brand-profile.js';
6
10
  import { createPostTool } from './tools/create-post.js';
7
11
  import { findSlotTool } from './tools/find-slot.js';
8
12
  import { listPostsTool } from './tools/list-posts.js';
@@ -16,7 +20,7 @@ import { getAccountAnalyticsTool } from './tools/get-account-analytics.js';
16
20
  import { getPostAnalyticsTool } from './tools/get-post-analytics.js';
17
21
  const server = new McpServer({
18
22
  name: 'posterly',
19
- version: '0.5.0',
23
+ version: '0.7.0',
20
24
  });
21
25
  let client;
22
26
  try {
@@ -45,6 +49,42 @@ server.tool(listAccountsTool.name, listAccountsTool.description, listAccountsToo
45
49
  return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
46
50
  }
47
51
  });
52
+ server.tool(listBrandsTool.name, listBrandsTool.description, listBrandsTool.inputSchema.shape, async (input) => {
53
+ try {
54
+ const text = await listBrandsTool.execute(client, input);
55
+ return { content: [{ type: 'text', text }] };
56
+ }
57
+ catch (err) {
58
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
59
+ }
60
+ });
61
+ server.tool(getBrandTool.name, getBrandTool.description, getBrandTool.inputSchema.shape, async (input) => {
62
+ try {
63
+ const text = await getBrandTool.execute(client, input);
64
+ return { content: [{ type: 'text', text }] };
65
+ }
66
+ catch (err) {
67
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
68
+ }
69
+ });
70
+ server.tool(listBrandAccountsTool.name, listBrandAccountsTool.description, listBrandAccountsTool.inputSchema.shape, async (input) => {
71
+ try {
72
+ const text = await listBrandAccountsTool.execute(client, input);
73
+ return { content: [{ type: 'text', text }] };
74
+ }
75
+ catch (err) {
76
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
77
+ }
78
+ });
79
+ server.tool(getBrandProfileTool.name, getBrandProfileTool.description, getBrandProfileTool.inputSchema.shape, async (input) => {
80
+ try {
81
+ const text = await getBrandProfileTool.execute(client, input);
82
+ return { content: [{ type: 'text', text }] };
83
+ }
84
+ catch (err) {
85
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
86
+ }
87
+ });
48
88
  server.tool(createPostTool.name, createPostTool.description, createPostTool.inputSchema.shape, async (input) => {
49
89
  try {
50
90
  const text = await createPostTool.execute(client, input);
@@ -6,6 +6,41 @@ export interface Account {
6
6
  profile_picture_url?: string;
7
7
  workspace_id?: string | null;
8
8
  }
9
+ export interface Brand {
10
+ id: string;
11
+ name: string;
12
+ workspace_id?: string | null;
13
+ workspace_client_id?: string | null;
14
+ legacy_brand_group_id?: string | null;
15
+ created_at?: string | null;
16
+ updated_at?: string | null;
17
+ source: 'canonical' | 'legacy';
18
+ account_count: number;
19
+ }
20
+ export interface BrandProfile {
21
+ id: string | null;
22
+ brand_group_id: string | null;
23
+ workspace_client_id: string | null;
24
+ brand_name: string;
25
+ tone_of_voice?: string | null;
26
+ audience?: string | null;
27
+ brand_values?: string | null;
28
+ do_donts?: unknown;
29
+ keywords?: unknown;
30
+ competitors?: unknown;
31
+ custom_instructions?: string | null;
32
+ visual_guidelines?: unknown;
33
+ logo_url?: string | null;
34
+ brand_story?: string | null;
35
+ example_posts?: unknown;
36
+ topics_to_cover?: unknown;
37
+ topics_to_avoid?: unknown;
38
+ voice_examples?: unknown;
39
+ last_context_refresh_at?: string | null;
40
+ created_at?: string | null;
41
+ updated_at?: string | null;
42
+ source: 'canonical' | 'legacy';
43
+ }
9
44
  export interface Post {
10
45
  id: number;
11
46
  content: string;
@@ -132,9 +167,20 @@ export declare class PosterlyClient {
132
167
  listAccounts(params?: {
133
168
  workspace_id?: string;
134
169
  }): Promise<Account[]>;
170
+ listBrands(params?: {
171
+ workspace_id?: string;
172
+ }): Promise<Brand[]>;
173
+ getBrand(id: string): Promise<{
174
+ brand: Brand;
175
+ }>;
176
+ listBrandAccounts(id: string): Promise<Account[]>;
177
+ getBrandProfile(id: string): Promise<{
178
+ brand_profile: BrandProfile;
179
+ }>;
135
180
  listPosts(params?: {
136
181
  status?: string;
137
182
  platform?: string;
183
+ account_id?: string;
138
184
  limit?: number;
139
185
  offset?: number;
140
186
  workspace_id?: string;
@@ -41,12 +41,32 @@ export class PosterlyClient {
41
41
  const data = await this.request('GET', `/accounts${qs ? `?${qs}` : ''}`);
42
42
  return data.accounts;
43
43
  }
44
+ async listBrands(params) {
45
+ const searchParams = new URLSearchParams();
46
+ if (params?.workspace_id)
47
+ searchParams.set('workspace_id', params.workspace_id);
48
+ const qs = searchParams.toString();
49
+ const data = await this.request('GET', `/brands${qs ? `?${qs}` : ''}`);
50
+ return data.brands;
51
+ }
52
+ async getBrand(id) {
53
+ return this.request('GET', `/brands/${encodeURIComponent(id)}`);
54
+ }
55
+ async listBrandAccounts(id) {
56
+ const data = await this.request('GET', `/brands/${encodeURIComponent(id)}/accounts`);
57
+ return data.accounts;
58
+ }
59
+ async getBrandProfile(id) {
60
+ return this.request('GET', `/brands/${encodeURIComponent(id)}/profile`);
61
+ }
44
62
  async listPosts(params) {
45
63
  const searchParams = new URLSearchParams();
46
64
  if (params?.status)
47
65
  searchParams.set('status', params.status);
48
66
  if (params?.platform)
49
67
  searchParams.set('platform', params.platform);
68
+ if (params?.account_id)
69
+ searchParams.set('account_id', params.account_id);
50
70
  if (params?.limit)
51
71
  searchParams.set('limit', String(params.limit));
52
72
  if (params?.offset)
@@ -7,42 +7,46 @@ export declare const createPostTool: {
7
7
  account_id: z.ZodOptional<z.ZodString>;
8
8
  username: z.ZodOptional<z.ZodString>;
9
9
  platform: z.ZodOptional<z.ZodString>;
10
- caption: z.ZodString;
10
+ caption: z.ZodOptional<z.ZodString>;
11
11
  scheduled_at: z.ZodOptional<z.ZodString>;
12
12
  media_url: z.ZodOptional<z.ZodString>;
13
13
  media_urls: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
14
14
  post_type: z.ZodOptional<z.ZodString>;
15
+ thread_posts: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
15
16
  workspace_id: z.ZodOptional<z.ZodString>;
16
17
  }, "strip", z.ZodTypeAny, {
17
- caption: string;
18
18
  workspace_id?: string | undefined;
19
19
  platform?: string | undefined;
20
20
  account_id?: string | undefined;
21
21
  username?: string | undefined;
22
+ caption?: string | undefined;
22
23
  scheduled_at?: string | undefined;
23
24
  media_url?: string | undefined;
24
25
  media_urls?: string[] | undefined;
25
26
  post_type?: string | undefined;
27
+ thread_posts?: string[] | undefined;
26
28
  }, {
27
- caption: string;
28
29
  workspace_id?: string | undefined;
29
30
  platform?: string | undefined;
30
31
  account_id?: string | undefined;
31
32
  username?: string | undefined;
33
+ caption?: string | undefined;
32
34
  scheduled_at?: string | undefined;
33
35
  media_url?: string | undefined;
34
36
  media_urls?: string[] | undefined;
35
37
  post_type?: string | undefined;
38
+ thread_posts?: string[] | undefined;
36
39
  }>;
37
40
  execute(client: PosterlyClient, input: {
38
41
  account_id?: string;
39
42
  username?: string;
40
43
  platform?: string;
41
- caption: string;
44
+ caption?: string;
42
45
  scheduled_at?: string;
43
46
  media_url?: string;
44
47
  media_urls?: string[];
45
48
  post_type?: string;
49
+ thread_posts?: string[];
46
50
  workspace_id?: string;
47
51
  }): Promise<string>;
48
52
  };
@@ -7,7 +7,8 @@ export const createPostTool = {
7
7
  '2. Show the user a preview containing ALL of: account(s) and platform(s), final caption text, scheduled time (in the user\'s timezone), media attached (if any), and workspace name.\n' +
8
8
  '3. Get explicit confirmation from the user (e.g. "post it", "yes schedule that", "looks good") BEFORE calling this tool. Do NOT infer consent from earlier instructions like "post about X every Monday" — confirm each individual post or the entire batch.\n' +
9
9
  '4. If scheduling multiple posts in one turn, list every post first and confirm the batch as a whole before calling create_post repeatedly.\n\n' +
10
- 'Provide either account_id OR username+platform to identify the account. If scheduled_at is omitted, the post publishes immediately. If workspace_id is omitted, the server resolves one from the social account, falling back to the caller\'s default (personal) workspace — pass workspace_id explicitly if the user has more than one workspace.',
10
+ 'Provide either account_id OR username+platform to identify the account. If scheduled_at is omitted, the post publishes immediately. If workspace_id is omitted, the server resolves one from the social account, falling back to the caller\'s default (personal) workspace — pass workspace_id explicitly if the user has more than one workspace.\n\n' +
11
+ 'THREADS: Pass `thread_posts` (an array of 2+ strings) to schedule a multi-post thread on X (Twitter) or Threads (Meta). The first entry is the lead post; the rest are published as replies in the same chain. X entries are capped at 280 characters each (4000 for verified, 25000 for organization accounts); Threads entries are capped at 500 characters each. When `thread_posts` is set, `caption` is ignored.',
11
12
  inputSchema: z.object({
12
13
  account_id: z.string().optional().describe('Social account ID (from list_accounts)'),
13
14
  username: z.string().optional().describe('Account username (alternative to account_id)'),
@@ -15,12 +16,12 @@ export const createPostTool = {
15
16
  .string()
16
17
  .optional()
17
18
  .describe('Platform name (required with username): instagram, twitter, linkedin, facebook, tiktok, threads, youtube, pinterest, google_business'),
18
- caption: z.string().describe('The post caption/text content'),
19
+ caption: z.string().optional().describe('The post caption/text content. Ignored when `thread_posts` is provided.'),
19
20
  scheduled_at: z
20
21
  .string()
21
22
  .optional()
22
23
  .describe('ISO 8601 datetime for scheduling (e.g. 2026-03-05T09:00:00Z). Omit for immediate publish.'),
23
- media_url: z.string().optional().describe('URL of media to attach (image or video)'),
24
+ media_url: z.string().optional().describe('URL of media to attach (image or video). For threads, attaches to the lead post only.'),
24
25
  media_urls: z
25
26
  .array(z.string())
26
27
  .optional()
@@ -28,14 +29,49 @@ export const createPostTool = {
28
29
  post_type: z
29
30
  .string()
30
31
  .optional()
31
- .describe('Post type: text, image, video, carousel, reel, story'),
32
+ .describe('Post type: text, image, video, carousel, reel, story. Auto-set to x_thread/threads_thread when `thread_posts` is provided.'),
33
+ thread_posts: z
34
+ .array(z.string())
35
+ .optional()
36
+ .describe('For X or Threads only: array of 2+ strings, one per post in the thread. The first entry leads, the rest reply in order. When set, the platform must be twitter or threads.'),
32
37
  workspace_id: z
33
38
  .string()
34
39
  .optional()
35
40
  .describe('Workspace ID to assign the post to (from whoami). If omitted, uses the account\'s workspace or the caller\'s default workspace.'),
36
41
  }),
37
42
  async execute(client, input) {
38
- const result = await client.createPost(input);
43
+ const { thread_posts, caption, post_type, ...rest } = input;
44
+ let payload;
45
+ if (thread_posts && thread_posts.length > 0) {
46
+ if (thread_posts.length < 2) {
47
+ throw new Error('thread_posts must contain at least 2 entries');
48
+ }
49
+ const platformHint = (input.platform || '').toLowerCase();
50
+ const isTwitter = platformHint === 'twitter' || platformHint === 'x';
51
+ const isThreads = platformHint === 'threads';
52
+ if (!isTwitter && !isThreads && !input.account_id) {
53
+ throw new Error('thread_posts requires `platform` to be "twitter" or "threads", or pass `account_id` for an X/Threads account.');
54
+ }
55
+ const arrayKey = isThreads ? 'threads_thread_posts' : 'x_thread_tweets';
56
+ const totalKey = isThreads ? 'threads_thread_total' : 'x_thread_total';
57
+ payload = {
58
+ ...rest,
59
+ platform: isTwitter ? 'twitter' : isThreads ? 'threads' : input.platform,
60
+ caption: thread_posts[0],
61
+ post_type: isThreads ? 'threads_thread' : 'x_thread',
62
+ metadata: {
63
+ [arrayKey]: thread_posts,
64
+ [totalKey]: thread_posts.length,
65
+ },
66
+ };
67
+ }
68
+ else {
69
+ if (!caption) {
70
+ throw new Error('caption is required (or pass `thread_posts` for an X/Threads thread)');
71
+ }
72
+ payload = { ...rest, caption, post_type };
73
+ }
74
+ const result = await client.createPost(payload);
39
75
  const p = result.post;
40
76
  const ws = result.workspace;
41
77
  const when = p.scheduled_at
@@ -49,6 +85,9 @@ export const createPostTool = {
49
85
  `• Status: ${p.status}`,
50
86
  `• Scheduled: ${when}`,
51
87
  ];
88
+ if (thread_posts) {
89
+ lines.push(`• Thread: ${thread_posts.length} posts`);
90
+ }
52
91
  if (ws) {
53
92
  lines.push(`• Workspace: ${ws.name} (${ws.id}) — resolved from ${ws.resolved_from}`);
54
93
  }
@@ -0,0 +1,16 @@
1
+ import { z } from 'zod';
2
+ import type { PosterlyClient } from '../lib/api-client.js';
3
+ export declare const getBrandProfileTool: {
4
+ name: string;
5
+ description: string;
6
+ inputSchema: z.ZodObject<{
7
+ brand_id: z.ZodString;
8
+ }, "strip", z.ZodTypeAny, {
9
+ brand_id: string;
10
+ }, {
11
+ brand_id: string;
12
+ }>;
13
+ execute(client: PosterlyClient, input: {
14
+ brand_id: string;
15
+ }): Promise<string>;
16
+ };
@@ -0,0 +1,73 @@
1
+ import { z } from 'zod';
2
+ export const getBrandProfileTool = {
3
+ name: 'get_brand_profile',
4
+ description: 'Get the extended brand profile for a brand/client. Returns voice/tone guidance, audience, keywords, dos and don’ts, visual notes, and other saved brand context.',
5
+ inputSchema: z.object({
6
+ brand_id: z.string().describe('The brand ID to inspect (from list_brands).'),
7
+ }),
8
+ async execute(client, input) {
9
+ const result = await client.getBrandProfile(input.brand_id);
10
+ const profile = result.brand_profile;
11
+ const lines = [
12
+ `Brand profile: ${profile.brand_name}`,
13
+ `• Source: ${profile.source}`,
14
+ ];
15
+ if (profile.workspace_client_id)
16
+ lines.push(`• Workspace client ID: ${profile.workspace_client_id}`);
17
+ if (profile.brand_group_id)
18
+ lines.push(`• Legacy brand group ID: ${profile.brand_group_id}`);
19
+ if (profile.tone_of_voice)
20
+ lines.push(`• Tone of voice: ${profile.tone_of_voice}`);
21
+ if (profile.audience)
22
+ lines.push(`• Audience: ${profile.audience}`);
23
+ if (profile.brand_values)
24
+ lines.push(`• Brand values: ${profile.brand_values}`);
25
+ if (profile.custom_instructions)
26
+ lines.push(`• Custom instructions: ${profile.custom_instructions}`);
27
+ if (profile.logo_url)
28
+ lines.push(`• Logo URL: ${profile.logo_url}`);
29
+ addStructuredLine(lines, 'Keywords', profile.keywords);
30
+ addStructuredLine(lines, 'Competitors', profile.competitors);
31
+ addStructuredLine(lines, 'Do / Don’ts', profile.do_donts);
32
+ addStructuredLine(lines, 'Visual guidelines', profile.visual_guidelines);
33
+ addStructuredLine(lines, 'Brand story', profile.brand_story);
34
+ addStructuredLine(lines, 'Example posts', profile.example_posts);
35
+ addStructuredLine(lines, 'Topics to cover', profile.topics_to_cover);
36
+ addStructuredLine(lines, 'Topics to avoid', profile.topics_to_avoid);
37
+ addStructuredLine(lines, 'Voice examples', profile.voice_examples);
38
+ if (profile.last_context_refresh_at) {
39
+ lines.push(`• Last refreshed: ${profile.last_context_refresh_at}`);
40
+ }
41
+ if (lines.length === 2) {
42
+ lines.push('• No extended brand profile fields have been saved yet.');
43
+ }
44
+ return lines.join('\n');
45
+ },
46
+ };
47
+ function addStructuredLine(lines, label, value) {
48
+ const formatted = formatStructuredValue(value);
49
+ if (formatted) {
50
+ lines.push(`• ${label}: ${formatted}`);
51
+ }
52
+ }
53
+ function formatStructuredValue(value) {
54
+ if (value == null)
55
+ return null;
56
+ if (typeof value === 'string') {
57
+ const trimmed = value.trim();
58
+ return trimmed.length > 0 ? trimmed : null;
59
+ }
60
+ if (Array.isArray(value)) {
61
+ if (value.length === 0)
62
+ return null;
63
+ return value
64
+ .map((item) => formatStructuredValue(item) || JSON.stringify(item))
65
+ .filter(Boolean)
66
+ .join(' | ');
67
+ }
68
+ if (typeof value === 'object') {
69
+ const json = JSON.stringify(value);
70
+ return json === '{}' ? null : json;
71
+ }
72
+ return String(value);
73
+ }
@@ -0,0 +1,16 @@
1
+ import { z } from 'zod';
2
+ import type { PosterlyClient } from '../lib/api-client.js';
3
+ export declare const getBrandTool: {
4
+ name: string;
5
+ description: string;
6
+ inputSchema: z.ZodObject<{
7
+ brand_id: z.ZodString;
8
+ }, "strip", z.ZodTypeAny, {
9
+ brand_id: string;
10
+ }, {
11
+ brand_id: string;
12
+ }>;
13
+ execute(client: PosterlyClient, input: {
14
+ brand_id: string;
15
+ }): Promise<string>;
16
+ };
@@ -0,0 +1,28 @@
1
+ import { z } from 'zod';
2
+ export const getBrandTool = {
3
+ name: 'get_brand',
4
+ description: 'Get one brand/client by ID. Returns its workspace, source, linked legacy brand group if present, and the number of social accounts assigned to it.',
5
+ inputSchema: z.object({
6
+ brand_id: z.string().describe('The brand ID to look up (from list_brands).'),
7
+ }),
8
+ async execute(client, input) {
9
+ const result = await client.getBrand(input.brand_id);
10
+ const brand = result.brand;
11
+ const lines = [
12
+ `Brand: ${brand.name}`,
13
+ `• ID: ${brand.id}`,
14
+ `• Source: ${brand.source}`,
15
+ `• Workspace: ${brand.workspace_id || 'N/A'}`,
16
+ `• Accounts assigned: ${brand.account_count ?? 0}`,
17
+ ];
18
+ if (brand.workspace_client_id)
19
+ lines.push(`• Workspace client ID: ${brand.workspace_client_id}`);
20
+ if (brand.legacy_brand_group_id)
21
+ lines.push(`• Legacy brand group ID: ${brand.legacy_brand_group_id}`);
22
+ if (brand.created_at)
23
+ lines.push(`• Created: ${brand.created_at}`);
24
+ if (brand.updated_at)
25
+ lines.push(`• Updated: ${brand.updated_at}`);
26
+ return lines.join('\n');
27
+ },
28
+ };
@@ -0,0 +1,16 @@
1
+ import { z } from 'zod';
2
+ import type { PosterlyClient } from '../lib/api-client.js';
3
+ export declare const listBrandAccountsTool: {
4
+ name: string;
5
+ description: string;
6
+ inputSchema: z.ZodObject<{
7
+ brand_id: z.ZodString;
8
+ }, "strip", z.ZodTypeAny, {
9
+ brand_id: string;
10
+ }, {
11
+ brand_id: string;
12
+ }>;
13
+ execute(client: PosterlyClient, input: {
14
+ brand_id: string;
15
+ }): Promise<string>;
16
+ };
@@ -0,0 +1,16 @@
1
+ import { z } from 'zod';
2
+ export const listBrandAccountsTool = {
3
+ name: 'list_brand_accounts',
4
+ description: 'List the connected social accounts assigned to a brand/client. Use this when a user refers to a brand name rather than a raw account handle.',
5
+ inputSchema: z.object({
6
+ brand_id: z.string().describe('The brand ID to inspect (from list_brands).'),
7
+ }),
8
+ async execute(client, input) {
9
+ const accounts = await client.listBrandAccounts(input.brand_id);
10
+ if (accounts.length === 0) {
11
+ return 'No social accounts are currently assigned to this brand.';
12
+ }
13
+ const lines = accounts.map((account) => `• ${account.platform} — @${account.username} (ID: ${account.id}${account.workspace_id ? `, ws: ${account.workspace_id}` : ''})`);
14
+ return `Brand accounts (${accounts.length}):\n${lines.join('\n')}`;
15
+ },
16
+ };
@@ -0,0 +1,16 @@
1
+ import { z } from 'zod';
2
+ import type { PosterlyClient } from '../lib/api-client.js';
3
+ export declare const listBrandsTool: {
4
+ name: string;
5
+ description: string;
6
+ inputSchema: z.ZodObject<{
7
+ workspace_id: z.ZodOptional<z.ZodString>;
8
+ }, "strip", z.ZodTypeAny, {
9
+ workspace_id?: string | undefined;
10
+ }, {
11
+ workspace_id?: string | undefined;
12
+ }>;
13
+ execute(client: PosterlyClient, input: {
14
+ workspace_id?: string;
15
+ }): Promise<string>;
16
+ };
@@ -0,0 +1,29 @@
1
+ import { z } from 'zod';
2
+ export const listBrandsTool = {
3
+ name: 'list_brands',
4
+ description: 'List brands/clients the caller can access. Returns each brand ID, name, workspace ID, source, and how many social accounts are currently assigned to it.',
5
+ inputSchema: z.object({
6
+ workspace_id: z
7
+ .string()
8
+ .optional()
9
+ .describe('Filter to brands in a specific workspace (get IDs via whoami).'),
10
+ }),
11
+ async execute(client, input) {
12
+ const brands = await client.listBrands({ workspace_id: input.workspace_id });
13
+ if (brands.length === 0) {
14
+ return input.workspace_id
15
+ ? 'No brands found in this workspace.'
16
+ : 'No brands found. Create brands in the posterly dashboard first.';
17
+ }
18
+ const lines = brands.map((brand) => {
19
+ const suffix = [
20
+ `ID: ${brand.id}`,
21
+ brand.workspace_id ? `ws: ${brand.workspace_id}` : null,
22
+ `accounts: ${brand.account_count}`,
23
+ brand.source,
24
+ ].filter(Boolean).join(', ');
25
+ return `• ${brand.name} (${suffix})`;
26
+ });
27
+ return `Brands (${brands.length}):\n${lines.join('\n')}`;
28
+ },
29
+ };
@@ -6,22 +6,26 @@ export declare const listPostsTool: {
6
6
  inputSchema: z.ZodObject<{
7
7
  status: z.ZodOptional<z.ZodString>;
8
8
  platform: z.ZodOptional<z.ZodString>;
9
+ account_id: z.ZodOptional<z.ZodString>;
9
10
  limit: z.ZodOptional<z.ZodNumber>;
10
11
  workspace_id: z.ZodOptional<z.ZodString>;
11
12
  }, "strip", z.ZodTypeAny, {
12
13
  workspace_id?: string | undefined;
13
14
  status?: string | undefined;
14
15
  platform?: string | undefined;
16
+ account_id?: string | undefined;
15
17
  limit?: number | undefined;
16
18
  }, {
17
19
  workspace_id?: string | undefined;
18
20
  status?: string | undefined;
19
21
  platform?: string | undefined;
22
+ account_id?: string | undefined;
20
23
  limit?: number | undefined;
21
24
  }>;
22
25
  execute(client: PosterlyClient, input: {
23
26
  status?: string;
24
27
  platform?: string;
28
+ account_id?: string;
25
29
  limit?: number;
26
30
  workspace_id?: string;
27
31
  }): Promise<string>;
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  export const listPostsTool = {
3
3
  name: 'list_posts',
4
- description: 'List upcoming or recent posts. Filter by status (scheduled, published, failed, draft), platform, or workspace_id. If workspace_id is omitted, posts across every workspace the caller is a member of are returned.',
4
+ description: 'List upcoming or recent posts. Filter by status (scheduled, published, failed, draft), platform, account_id, or workspace_id. If workspace_id is omitted, posts across every workspace the caller is a member of are returned.',
5
5
  inputSchema: z.object({
6
6
  status: z
7
7
  .string()
@@ -11,12 +11,16 @@ export const listPostsTool = {
11
11
  .string()
12
12
  .optional()
13
13
  .describe('Filter by platform: instagram, twitter, linkedin, etc.'),
14
+ account_id: z
15
+ .string()
16
+ .optional()
17
+ .describe('Filter to a specific social account ID (from list_accounts).'),
14
18
  limit: z
15
19
  .number()
16
20
  .min(1)
17
- .max(50)
21
+ .max(100)
18
22
  .optional()
19
- .describe('Number of posts to return (default 10, max 50)'),
23
+ .describe('Number of posts to return (default 20, max 100)'),
20
24
  workspace_id: z
21
25
  .string()
22
26
  .optional()
@@ -25,7 +29,7 @@ export const listPostsTool = {
25
29
  async execute(client, input) {
26
30
  const { posts, total } = await client.listPosts({
27
31
  ...input,
28
- limit: input.limit || 10,
32
+ limit: input.limit || 20,
29
33
  });
30
34
  if (posts.length === 0) {
31
35
  return 'No posts found matching your criteria.';
package/package.json CHANGED
@@ -1,8 +1,27 @@
1
1
  {
2
2
  "name": "posterly-mcp-server",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "MCP server for posterly — schedule social media posts from Claude Desktop",
5
5
  "license": "MIT",
6
+ "homepage": "https://www.poster.ly/mcp",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/awpthorp/posterly.git",
10
+ "directory": "mcp-server"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/awpthorp/posterly/issues"
14
+ },
15
+ "keywords": [
16
+ "mcp",
17
+ "model-context-protocol",
18
+ "posterly",
19
+ "social-media",
20
+ "scheduling",
21
+ "claude",
22
+ "cursor",
23
+ "chatgpt"
24
+ ],
6
25
  "type": "module",
7
26
  "bin": {
8
27
  "posterly-mcp": "./dist/index.js"
package/src/index.ts CHANGED
@@ -4,6 +4,10 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
4
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
5
  import { PosterlyClient } from './lib/api-client.js';
6
6
  import { listAccountsTool } from './tools/list-accounts.js';
7
+ import { listBrandsTool } from './tools/list-brands.js';
8
+ import { getBrandTool } from './tools/get-brand.js';
9
+ import { listBrandAccountsTool } from './tools/list-brand-accounts.js';
10
+ import { getBrandProfileTool } from './tools/get-brand-profile.js';
7
11
  import { createPostTool } from './tools/create-post.js';
8
12
  import { findSlotTool } from './tools/find-slot.js';
9
13
  import { listPostsTool } from './tools/list-posts.js';
@@ -18,7 +22,7 @@ import { getPostAnalyticsTool } from './tools/get-post-analytics.js';
18
22
 
19
23
  const server = new McpServer({
20
24
  name: 'posterly',
21
- version: '0.5.0',
25
+ version: '0.7.0',
22
26
  });
23
27
 
24
28
  let client: PosterlyClient;
@@ -59,6 +63,62 @@ server.tool(
59
63
  }
60
64
  );
61
65
 
66
+ server.tool(
67
+ listBrandsTool.name,
68
+ listBrandsTool.description,
69
+ listBrandsTool.inputSchema.shape,
70
+ async (input) => {
71
+ try {
72
+ const text = await listBrandsTool.execute(client, input as any);
73
+ return { content: [{ type: 'text' as const, text }] };
74
+ } catch (err: any) {
75
+ return { content: [{ type: 'text' as const, text: `Error: ${err.message}` }], isError: true };
76
+ }
77
+ }
78
+ );
79
+
80
+ server.tool(
81
+ getBrandTool.name,
82
+ getBrandTool.description,
83
+ getBrandTool.inputSchema.shape,
84
+ async (input) => {
85
+ try {
86
+ const text = await getBrandTool.execute(client, input as any);
87
+ return { content: [{ type: 'text' as const, text }] };
88
+ } catch (err: any) {
89
+ return { content: [{ type: 'text' as const, text: `Error: ${err.message}` }], isError: true };
90
+ }
91
+ }
92
+ );
93
+
94
+ server.tool(
95
+ listBrandAccountsTool.name,
96
+ listBrandAccountsTool.description,
97
+ listBrandAccountsTool.inputSchema.shape,
98
+ async (input) => {
99
+ try {
100
+ const text = await listBrandAccountsTool.execute(client, input as any);
101
+ return { content: [{ type: 'text' as const, text }] };
102
+ } catch (err: any) {
103
+ return { content: [{ type: 'text' as const, text: `Error: ${err.message}` }], isError: true };
104
+ }
105
+ }
106
+ );
107
+
108
+ server.tool(
109
+ getBrandProfileTool.name,
110
+ getBrandProfileTool.description,
111
+ getBrandProfileTool.inputSchema.shape,
112
+ async (input) => {
113
+ try {
114
+ const text = await getBrandProfileTool.execute(client, input as any);
115
+ return { content: [{ type: 'text' as const, text }] };
116
+ } catch (err: any) {
117
+ return { content: [{ type: 'text' as const, text: `Error: ${err.message}` }], isError: true };
118
+ }
119
+ }
120
+ );
121
+
62
122
  server.tool(
63
123
  createPostTool.name,
64
124
  createPostTool.description,
@@ -10,6 +10,43 @@ export interface Account {
10
10
  workspace_id?: string | null;
11
11
  }
12
12
 
13
+ export interface Brand {
14
+ id: string;
15
+ name: string;
16
+ workspace_id?: string | null;
17
+ workspace_client_id?: string | null;
18
+ legacy_brand_group_id?: string | null;
19
+ created_at?: string | null;
20
+ updated_at?: string | null;
21
+ source: 'canonical' | 'legacy';
22
+ account_count: number;
23
+ }
24
+
25
+ export interface BrandProfile {
26
+ id: string | null;
27
+ brand_group_id: string | null;
28
+ workspace_client_id: string | null;
29
+ brand_name: string;
30
+ tone_of_voice?: string | null;
31
+ audience?: string | null;
32
+ brand_values?: string | null;
33
+ do_donts?: unknown;
34
+ keywords?: unknown;
35
+ competitors?: unknown;
36
+ custom_instructions?: string | null;
37
+ visual_guidelines?: unknown;
38
+ logo_url?: string | null;
39
+ brand_story?: string | null;
40
+ example_posts?: unknown;
41
+ topics_to_cover?: unknown;
42
+ topics_to_avoid?: unknown;
43
+ voice_examples?: unknown;
44
+ last_context_refresh_at?: string | null;
45
+ created_at?: string | null;
46
+ updated_at?: string | null;
47
+ source: 'canonical' | 'legacy';
48
+ }
49
+
13
50
  export interface Post {
14
51
  id: number;
15
52
  content: string;
@@ -170,9 +207,31 @@ export class PosterlyClient {
170
207
  return data.accounts;
171
208
  }
172
209
 
210
+ async listBrands(params?: { workspace_id?: string }): Promise<Brand[]> {
211
+ const searchParams = new URLSearchParams();
212
+ if (params?.workspace_id) searchParams.set('workspace_id', params.workspace_id);
213
+ const qs = searchParams.toString();
214
+ const data = await this.request<{ brands: Brand[] }>('GET', `/brands${qs ? `?${qs}` : ''}`);
215
+ return data.brands;
216
+ }
217
+
218
+ async getBrand(id: string): Promise<{ brand: Brand }> {
219
+ return this.request('GET', `/brands/${encodeURIComponent(id)}`);
220
+ }
221
+
222
+ async listBrandAccounts(id: string): Promise<Account[]> {
223
+ const data = await this.request<{ accounts: Account[] }>('GET', `/brands/${encodeURIComponent(id)}/accounts`);
224
+ return data.accounts;
225
+ }
226
+
227
+ async getBrandProfile(id: string): Promise<{ brand_profile: BrandProfile }> {
228
+ return this.request('GET', `/brands/${encodeURIComponent(id)}/profile`);
229
+ }
230
+
173
231
  async listPosts(params?: {
174
232
  status?: string;
175
233
  platform?: string;
234
+ account_id?: string;
176
235
  limit?: number;
177
236
  offset?: number;
178
237
  workspace_id?: string;
@@ -180,6 +239,7 @@ export class PosterlyClient {
180
239
  const searchParams = new URLSearchParams();
181
240
  if (params?.status) searchParams.set('status', params.status);
182
241
  if (params?.platform) searchParams.set('platform', params.platform);
242
+ if (params?.account_id) searchParams.set('account_id', params.account_id);
183
243
  if (params?.limit) searchParams.set('limit', String(params.limit));
184
244
  if (params?.offset) searchParams.set('offset', String(params.offset));
185
245
  if (params?.workspace_id) searchParams.set('workspace_id', params.workspace_id);
@@ -10,7 +10,8 @@ export const createPostTool = {
10
10
  '2. Show the user a preview containing ALL of: account(s) and platform(s), final caption text, scheduled time (in the user\'s timezone), media attached (if any), and workspace name.\n' +
11
11
  '3. Get explicit confirmation from the user (e.g. "post it", "yes schedule that", "looks good") BEFORE calling this tool. Do NOT infer consent from earlier instructions like "post about X every Monday" — confirm each individual post or the entire batch.\n' +
12
12
  '4. If scheduling multiple posts in one turn, list every post first and confirm the batch as a whole before calling create_post repeatedly.\n\n' +
13
- 'Provide either account_id OR username+platform to identify the account. If scheduled_at is omitted, the post publishes immediately. If workspace_id is omitted, the server resolves one from the social account, falling back to the caller\'s default (personal) workspace — pass workspace_id explicitly if the user has more than one workspace.',
13
+ 'Provide either account_id OR username+platform to identify the account. If scheduled_at is omitted, the post publishes immediately. If workspace_id is omitted, the server resolves one from the social account, falling back to the caller\'s default (personal) workspace — pass workspace_id explicitly if the user has more than one workspace.\n\n' +
14
+ 'THREADS: Pass `thread_posts` (an array of 2+ strings) to schedule a multi-post thread on X (Twitter) or Threads (Meta). The first entry is the lead post; the rest are published as replies in the same chain. X entries are capped at 280 characters each (4000 for verified, 25000 for organization accounts); Threads entries are capped at 500 characters each. When `thread_posts` is set, `caption` is ignored.',
14
15
  inputSchema: z.object({
15
16
  account_id: z.string().optional().describe('Social account ID (from list_accounts)'),
16
17
  username: z.string().optional().describe('Account username (alternative to account_id)'),
@@ -18,12 +19,12 @@ export const createPostTool = {
18
19
  .string()
19
20
  .optional()
20
21
  .describe('Platform name (required with username): instagram, twitter, linkedin, facebook, tiktok, threads, youtube, pinterest, google_business'),
21
- caption: z.string().describe('The post caption/text content'),
22
+ caption: z.string().optional().describe('The post caption/text content. Ignored when `thread_posts` is provided.'),
22
23
  scheduled_at: z
23
24
  .string()
24
25
  .optional()
25
26
  .describe('ISO 8601 datetime for scheduling (e.g. 2026-03-05T09:00:00Z). Omit for immediate publish.'),
26
- media_url: z.string().optional().describe('URL of media to attach (image or video)'),
27
+ media_url: z.string().optional().describe('URL of media to attach (image or video). For threads, attaches to the lead post only.'),
27
28
  media_urls: z
28
29
  .array(z.string())
29
30
  .optional()
@@ -31,7 +32,11 @@ export const createPostTool = {
31
32
  post_type: z
32
33
  .string()
33
34
  .optional()
34
- .describe('Post type: text, image, video, carousel, reel, story'),
35
+ .describe('Post type: text, image, video, carousel, reel, story. Auto-set to x_thread/threads_thread when `thread_posts` is provided.'),
36
+ thread_posts: z
37
+ .array(z.string())
38
+ .optional()
39
+ .describe('For X or Threads only: array of 2+ strings, one per post in the thread. The first entry leads, the rest reply in order. When set, the platform must be twitter or threads.'),
35
40
  workspace_id: z
36
41
  .string()
37
42
  .optional()
@@ -44,15 +49,50 @@ export const createPostTool = {
44
49
  account_id?: string;
45
50
  username?: string;
46
51
  platform?: string;
47
- caption: string;
52
+ caption?: string;
48
53
  scheduled_at?: string;
49
54
  media_url?: string;
50
55
  media_urls?: string[];
51
56
  post_type?: string;
57
+ thread_posts?: string[];
52
58
  workspace_id?: string;
53
59
  }
54
60
  ) {
55
- const result = await client.createPost(input);
61
+ const { thread_posts, caption, post_type, ...rest } = input;
62
+ let payload: Parameters<typeof client.createPost>[0];
63
+
64
+ if (thread_posts && thread_posts.length > 0) {
65
+ if (thread_posts.length < 2) {
66
+ throw new Error('thread_posts must contain at least 2 entries');
67
+ }
68
+ const platformHint = (input.platform || '').toLowerCase();
69
+ const isTwitter = platformHint === 'twitter' || platformHint === 'x';
70
+ const isThreads = platformHint === 'threads';
71
+ if (!isTwitter && !isThreads && !input.account_id) {
72
+ throw new Error(
73
+ 'thread_posts requires `platform` to be "twitter" or "threads", or pass `account_id` for an X/Threads account.',
74
+ );
75
+ }
76
+ const arrayKey = isThreads ? 'threads_thread_posts' : 'x_thread_tweets';
77
+ const totalKey = isThreads ? 'threads_thread_total' : 'x_thread_total';
78
+ payload = {
79
+ ...rest,
80
+ platform: isTwitter ? 'twitter' : isThreads ? 'threads' : input.platform,
81
+ caption: thread_posts[0],
82
+ post_type: isThreads ? 'threads_thread' : 'x_thread',
83
+ metadata: {
84
+ [arrayKey]: thread_posts,
85
+ [totalKey]: thread_posts.length,
86
+ },
87
+ };
88
+ } else {
89
+ if (!caption) {
90
+ throw new Error('caption is required (or pass `thread_posts` for an X/Threads thread)');
91
+ }
92
+ payload = { ...rest, caption, post_type };
93
+ }
94
+
95
+ const result = await client.createPost(payload);
56
96
  const p = result.post as Record<string, any>;
57
97
  const ws = result.workspace;
58
98
 
@@ -68,6 +108,9 @@ export const createPostTool = {
68
108
  `• Status: ${p.status}`,
69
109
  `• Scheduled: ${when}`,
70
110
  ];
111
+ if (thread_posts) {
112
+ lines.push(`• Thread: ${thread_posts.length} posts`);
113
+ }
71
114
  if (ws) {
72
115
  lines.push(`• Workspace: ${ws.name} (${ws.id}) — resolved from ${ws.resolved_from}`);
73
116
  }
@@ -0,0 +1,79 @@
1
+ import { z } from 'zod';
2
+ import type { PosterlyClient } from '../lib/api-client.js';
3
+
4
+ export const getBrandProfileTool = {
5
+ name: 'get_brand_profile',
6
+ description:
7
+ 'Get the extended brand profile for a brand/client. Returns voice/tone guidance, audience, keywords, dos and don’ts, visual notes, and other saved brand context.',
8
+ inputSchema: z.object({
9
+ brand_id: z.string().describe('The brand ID to inspect (from list_brands).'),
10
+ }),
11
+
12
+ async execute(
13
+ client: PosterlyClient,
14
+ input: { brand_id: string },
15
+ ) {
16
+ const result = await client.getBrandProfile(input.brand_id);
17
+ const profile = result.brand_profile as Record<string, any>;
18
+
19
+ const lines = [
20
+ `Brand profile: ${profile.brand_name}`,
21
+ `• Source: ${profile.source}`,
22
+ ];
23
+
24
+ if (profile.workspace_client_id) lines.push(`• Workspace client ID: ${profile.workspace_client_id}`);
25
+ if (profile.brand_group_id) lines.push(`• Legacy brand group ID: ${profile.brand_group_id}`);
26
+ if (profile.tone_of_voice) lines.push(`• Tone of voice: ${profile.tone_of_voice}`);
27
+ if (profile.audience) lines.push(`• Audience: ${profile.audience}`);
28
+ if (profile.brand_values) lines.push(`• Brand values: ${profile.brand_values}`);
29
+ if (profile.custom_instructions) lines.push(`• Custom instructions: ${profile.custom_instructions}`);
30
+ if (profile.logo_url) lines.push(`• Logo URL: ${profile.logo_url}`);
31
+
32
+ addStructuredLine(lines, 'Keywords', profile.keywords);
33
+ addStructuredLine(lines, 'Competitors', profile.competitors);
34
+ addStructuredLine(lines, 'Do / Don’ts', profile.do_donts);
35
+ addStructuredLine(lines, 'Visual guidelines', profile.visual_guidelines);
36
+ addStructuredLine(lines, 'Brand story', profile.brand_story);
37
+ addStructuredLine(lines, 'Example posts', profile.example_posts);
38
+ addStructuredLine(lines, 'Topics to cover', profile.topics_to_cover);
39
+ addStructuredLine(lines, 'Topics to avoid', profile.topics_to_avoid);
40
+ addStructuredLine(lines, 'Voice examples', profile.voice_examples);
41
+
42
+ if (profile.last_context_refresh_at) {
43
+ lines.push(`• Last refreshed: ${profile.last_context_refresh_at}`);
44
+ }
45
+
46
+ if (lines.length === 2) {
47
+ lines.push('• No extended brand profile fields have been saved yet.');
48
+ }
49
+
50
+ return lines.join('\n');
51
+ },
52
+ };
53
+
54
+ function addStructuredLine(lines: string[], label: string, value: unknown) {
55
+ const formatted = formatStructuredValue(value);
56
+ if (formatted) {
57
+ lines.push(`• ${label}: ${formatted}`);
58
+ }
59
+ }
60
+
61
+ function formatStructuredValue(value: unknown): string | null {
62
+ if (value == null) return null;
63
+ if (typeof value === 'string') {
64
+ const trimmed = value.trim();
65
+ return trimmed.length > 0 ? trimmed : null;
66
+ }
67
+ if (Array.isArray(value)) {
68
+ if (value.length === 0) return null;
69
+ return value
70
+ .map((item) => formatStructuredValue(item) || JSON.stringify(item))
71
+ .filter(Boolean)
72
+ .join(' | ');
73
+ }
74
+ if (typeof value === 'object') {
75
+ const json = JSON.stringify(value);
76
+ return json === '{}' ? null : json;
77
+ }
78
+ return String(value);
79
+ }
@@ -0,0 +1,34 @@
1
+ import { z } from 'zod';
2
+ import type { PosterlyClient } from '../lib/api-client.js';
3
+
4
+ export const getBrandTool = {
5
+ name: 'get_brand',
6
+ description:
7
+ 'Get one brand/client by ID. Returns its workspace, source, linked legacy brand group if present, and the number of social accounts assigned to it.',
8
+ inputSchema: z.object({
9
+ brand_id: z.string().describe('The brand ID to look up (from list_brands).'),
10
+ }),
11
+
12
+ async execute(
13
+ client: PosterlyClient,
14
+ input: { brand_id: string },
15
+ ) {
16
+ const result = await client.getBrand(input.brand_id);
17
+ const brand = result.brand as Record<string, any>;
18
+
19
+ const lines = [
20
+ `Brand: ${brand.name}`,
21
+ `• ID: ${brand.id}`,
22
+ `• Source: ${brand.source}`,
23
+ `• Workspace: ${brand.workspace_id || 'N/A'}`,
24
+ `• Accounts assigned: ${brand.account_count ?? 0}`,
25
+ ];
26
+
27
+ if (brand.workspace_client_id) lines.push(`• Workspace client ID: ${brand.workspace_client_id}`);
28
+ if (brand.legacy_brand_group_id) lines.push(`• Legacy brand group ID: ${brand.legacy_brand_group_id}`);
29
+ if (brand.created_at) lines.push(`• Created: ${brand.created_at}`);
30
+ if (brand.updated_at) lines.push(`• Updated: ${brand.updated_at}`);
31
+
32
+ return lines.join('\n');
33
+ },
34
+ };
@@ -0,0 +1,28 @@
1
+ import { z } from 'zod';
2
+ import type { PosterlyClient } from '../lib/api-client.js';
3
+
4
+ export const listBrandAccountsTool = {
5
+ name: 'list_brand_accounts',
6
+ description:
7
+ 'List the connected social accounts assigned to a brand/client. Use this when a user refers to a brand name rather than a raw account handle.',
8
+ inputSchema: z.object({
9
+ brand_id: z.string().describe('The brand ID to inspect (from list_brands).'),
10
+ }),
11
+
12
+ async execute(
13
+ client: PosterlyClient,
14
+ input: { brand_id: string },
15
+ ) {
16
+ const accounts = await client.listBrandAccounts(input.brand_id);
17
+
18
+ if (accounts.length === 0) {
19
+ return 'No social accounts are currently assigned to this brand.';
20
+ }
21
+
22
+ const lines = accounts.map(
23
+ (account) => `• ${account.platform} — @${account.username} (ID: ${account.id}${account.workspace_id ? `, ws: ${account.workspace_id}` : ''})`
24
+ );
25
+
26
+ return `Brand accounts (${accounts.length}):\n${lines.join('\n')}`;
27
+ },
28
+ };
@@ -0,0 +1,36 @@
1
+ import { z } from 'zod';
2
+ import type { PosterlyClient } from '../lib/api-client.js';
3
+
4
+ export const listBrandsTool = {
5
+ name: 'list_brands',
6
+ description:
7
+ 'List brands/clients the caller can access. Returns each brand ID, name, workspace ID, source, and how many social accounts are currently assigned to it.',
8
+ inputSchema: z.object({
9
+ workspace_id: z
10
+ .string()
11
+ .optional()
12
+ .describe('Filter to brands in a specific workspace (get IDs via whoami).'),
13
+ }),
14
+
15
+ async execute(client: PosterlyClient, input: { workspace_id?: string }) {
16
+ const brands = await client.listBrands({ workspace_id: input.workspace_id });
17
+
18
+ if (brands.length === 0) {
19
+ return input.workspace_id
20
+ ? 'No brands found in this workspace.'
21
+ : 'No brands found. Create brands in the posterly dashboard first.';
22
+ }
23
+
24
+ const lines = brands.map((brand) => {
25
+ const suffix = [
26
+ `ID: ${brand.id}`,
27
+ brand.workspace_id ? `ws: ${brand.workspace_id}` : null,
28
+ `accounts: ${brand.account_count}`,
29
+ brand.source,
30
+ ].filter(Boolean).join(', ');
31
+ return `• ${brand.name} (${suffix})`;
32
+ });
33
+
34
+ return `Brands (${brands.length}):\n${lines.join('\n')}`;
35
+ },
36
+ };
@@ -4,7 +4,7 @@ import type { PosterlyClient } from '../lib/api-client.js';
4
4
  export const listPostsTool = {
5
5
  name: 'list_posts',
6
6
  description:
7
- 'List upcoming or recent posts. Filter by status (scheduled, published, failed, draft), platform, or workspace_id. If workspace_id is omitted, posts across every workspace the caller is a member of are returned.',
7
+ 'List upcoming or recent posts. Filter by status (scheduled, published, failed, draft), platform, account_id, or workspace_id. If workspace_id is omitted, posts across every workspace the caller is a member of are returned.',
8
8
  inputSchema: z.object({
9
9
  status: z
10
10
  .string()
@@ -14,12 +14,16 @@ export const listPostsTool = {
14
14
  .string()
15
15
  .optional()
16
16
  .describe('Filter by platform: instagram, twitter, linkedin, etc.'),
17
+ account_id: z
18
+ .string()
19
+ .optional()
20
+ .describe('Filter to a specific social account ID (from list_accounts).'),
17
21
  limit: z
18
22
  .number()
19
23
  .min(1)
20
- .max(50)
24
+ .max(100)
21
25
  .optional()
22
- .describe('Number of posts to return (default 10, max 50)'),
26
+ .describe('Number of posts to return (default 20, max 100)'),
23
27
  workspace_id: z
24
28
  .string()
25
29
  .optional()
@@ -28,11 +32,17 @@ export const listPostsTool = {
28
32
 
29
33
  async execute(
30
34
  client: PosterlyClient,
31
- input: { status?: string; platform?: string; limit?: number; workspace_id?: string }
35
+ input: {
36
+ status?: string;
37
+ platform?: string;
38
+ account_id?: string;
39
+ limit?: number;
40
+ workspace_id?: string;
41
+ }
32
42
  ) {
33
43
  const { posts, total } = await client.listPosts({
34
44
  ...input,
35
- limit: input.limit || 10,
45
+ limit: input.limit || 20,
36
46
  });
37
47
 
38
48
  if (posts.length === 0) {