posterly-mcp-server 0.5.0 → 0.6.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,175 @@
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.6.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
+ - `100 requests/hour` per API key
149
+ - works across all 11 supported platforms
150
+
151
+ Details: [poster.ly/dashboard/api](https://www.poster.ly/dashboard/api)
152
+
153
+ ## Links
154
+
155
+ - Docs: [poster.ly/mcp](https://www.poster.ly/mcp)
156
+ - OpenClaw skill: [poster.ly/openclaw](https://www.poster.ly/openclaw)
157
+ - API add-on: [poster.ly/dashboard/api](https://www.poster.ly/dashboard/api)
158
+ - MCP server card: [/.well-known/mcp/server-card.json](https://www.poster.ly/.well-known/mcp/server-card.json)
159
+ - OAuth authorization server metadata: [/.well-known/oauth-authorization-server](https://www.poster.ly/.well-known/oauth-authorization-server)
160
+ - OAuth protected resource metadata: [/.well-known/oauth-protected-resource](https://www.poster.ly/.well-known/oauth-protected-resource)
161
+
162
+ ## Development
163
+
164
+ From the `mcp-server` directory:
165
+
166
+ ```bash
167
+ npm install
168
+ npm run build
169
+ npm start
170
+ ```
171
+
172
+ The package reads:
173
+
174
+ - `POSTERLY_API_KEY`
175
+ - 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.6.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)
@@ -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.6.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.6.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);
@@ -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) {