posterly-mcp-server 0.33.2 → 0.34.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.
@@ -572,10 +572,33 @@ export interface GoogleBusinessReview {
572
572
  account_id?: number;
573
573
  }
574
574
 
575
+ export interface GoogleBusinessLocationReviewSummary {
576
+ id: number;
577
+ location_id: string | null;
578
+ name: string | null;
579
+ business_name: string | null;
580
+ workspace_id: string | null;
581
+ /** Google's own review count for this location, or null when Google didn't report one. */
582
+ total_review_count: number | null;
583
+ average_rating: number | null;
584
+ fetched_review_count: number;
585
+ /** Why this location's reviews are missing or incomplete, else null. */
586
+ error: string | null;
587
+ }
588
+
575
589
  export interface GoogleBusinessReviewsResponse {
576
590
  reviews: GoogleBusinessReview[];
591
+ /** Reviews matching the filters across every page, not just the ones returned. */
577
592
  total: number;
578
- locations: Array<Record<string, unknown>>;
593
+ returned?: number;
594
+ limit?: number;
595
+ offset?: number;
596
+ has_more?: boolean;
597
+ /** Reviews fetched from Google before rating/unanswered filters. */
598
+ fetched?: number;
599
+ /** Google's reported review count summed across the selected locations. */
600
+ total_review_count?: number | null;
601
+ locations: Array<GoogleBusinessLocationReviewSummary | Record<string, unknown>>;
579
602
  }
580
603
 
581
604
  export interface GoogleBusinessMediaItem {
@@ -737,6 +760,17 @@ export interface ConnectSession {
737
760
  expires_at: string;
738
761
  }
739
762
 
763
+ export interface ConnectAccountCredentialsResponse {
764
+ connected: boolean;
765
+ account: {
766
+ id: number;
767
+ platform: string;
768
+ username: string;
769
+ [extra: string]: unknown;
770
+ };
771
+ workspace_id: string;
772
+ }
773
+
740
774
  export interface OAuthDeveloperClient {
741
775
  id: string;
742
776
  client_id: string;
@@ -1067,6 +1101,19 @@ export class PosterlyClient {
1067
1101
  return this.request('GET', `/connect/sessions/${encodeURIComponent(sessionId)}`);
1068
1102
  }
1069
1103
 
1104
+ async connectAccountCredentials(data: {
1105
+ platform: string;
1106
+ credentials: Record<string, string>;
1107
+ workspace_id?: string;
1108
+ connect_session_id?: string;
1109
+ }): Promise<ConnectAccountCredentialsResponse> {
1110
+ const { platform, credentials, workspace_id, connect_session_id } = data;
1111
+ const body: Record<string, unknown> = { credentials };
1112
+ if (workspace_id) body.workspace_id = workspace_id;
1113
+ if (connect_session_id) body.connect_session_id = connect_session_id;
1114
+ return this.request('POST', `/connect/${encodeURIComponent(platform)}/credentials`, body);
1115
+ }
1116
+
1070
1117
  async listOAuthClients(): Promise<{ clients: OAuthDeveloperClient[] }> {
1071
1118
  return this.request('GET', '/oauth/clients');
1072
1119
  }
@@ -1375,6 +1422,8 @@ export class PosterlyClient {
1375
1422
  location_id?: string;
1376
1423
  rating?: number;
1377
1424
  unanswered?: boolean;
1425
+ limit?: number;
1426
+ offset?: number;
1378
1427
  }): Promise<GoogleBusinessReviewsResponse> {
1379
1428
  const searchParams = new URLSearchParams();
1380
1429
  if (params?.workspace_id) searchParams.set('workspace_id', params.workspace_id);
@@ -1382,6 +1431,8 @@ export class PosterlyClient {
1382
1431
  if (params?.location_id) searchParams.set('location_id', params.location_id);
1383
1432
  if (params?.rating) searchParams.set('rating', String(params.rating));
1384
1433
  if (params?.unanswered) searchParams.set('unanswered', 'true');
1434
+ if (params?.limit) searchParams.set('limit', String(params.limit));
1435
+ if (params?.offset) searchParams.set('offset', String(params.offset));
1385
1436
  const qs = searchParams.toString();
1386
1437
  return this.request('GET', `/google-business/reviews${qs ? `?${qs}` : ''}`);
1387
1438
  }
@@ -5,4 +5,4 @@
5
5
  // tool set (minus the intentional pre-auth signup tools that only this stdio
6
6
  // package exposes). `npm run check:mcp-parity` enforces both the version match
7
7
  // and the tool-list match, and runs in the pre-commit hook.
8
- export const POSTERLY_MCP_VERSION = '0.33.2';
8
+ export const POSTERLY_MCP_VERSION = '0.34.0';
@@ -0,0 +1,91 @@
1
+ import { z } from 'zod';
2
+ import type { PosterlyClient } from '../lib/api-client.js';
3
+
4
+ const CREDENTIAL_PLATFORMS = [
5
+ 'telegram',
6
+ 'bluesky',
7
+ 'discord',
8
+ 'wordpress',
9
+ 'devto',
10
+ 'hashnode',
11
+ 'lemmy',
12
+ ] as const;
13
+
14
+ /**
15
+ * Per-platform credential field whitelist. Duplicated in the hosted tool
16
+ * (lib/mcp/http-tools.ts) on purpose: the platform manifest does not carry
17
+ * credential fields, and the v1 endpoint rejects unknown keys, so each tool
18
+ * must whitelist before building the credentials object. The MCP parity and
19
+ * payload checks guard the duplication.
20
+ */
21
+ const PLATFORM_CREDENTIAL_FIELDS: Record<string, string[]> = {
22
+ telegram: ['bot_token', 'chat_id'],
23
+ bluesky: ['handle', 'app_password'],
24
+ discord: ['webhook_url'],
25
+ wordpress: ['site_url', 'username', 'app_password'],
26
+ devto: ['api_key'],
27
+ hashnode: ['pat'],
28
+ lemmy: ['instance', 'username', 'password'],
29
+ };
30
+
31
+ export const connectAccountTool = {
32
+ name: 'connect_account',
33
+ description:
34
+ 'Connect a credential-based social account (telegram, bluesky, discord, wordpress, devto, hashnode, lemmy) directly, without a browser session. Call get_connect_link first to discover the exact credential fields the platform needs. Prefer scoped secrets: app passwords (Bluesky, WordPress), bot tokens (Telegram), webhook URLs (Discord), and API tokens (Dev.to, Hashnode) over primary passwords. Warn the user that any credential they share passes through this conversation. For OAuth platforms (Instagram, X, LinkedIn, ...) use create_connect_session instead.',
35
+ inputSchema: z.object({
36
+ platform: z
37
+ .enum(CREDENTIAL_PLATFORMS)
38
+ .describe('Credential-based connection target: telegram, bluesky, discord, wordpress, devto, hashnode, or lemmy.'),
39
+ bot_token: z.string().optional().describe('Telegram: bot token from BotFather.'),
40
+ chat_id: z.string().optional().describe('Telegram: target channel or group chat ID.'),
41
+ handle: z.string().optional().describe('Bluesky: handle, for example posterly.bsky.social.'),
42
+ app_password: z.string().optional().describe('Bluesky: app password from account settings. WordPress: application password from wp-admin Users, Profile, Application Passwords.'),
43
+ webhook_url: z.string().optional().describe('Discord: channel webhook URL from channel settings, Integrations, Webhooks.'),
44
+ site_url: z.string().optional().describe('WordPress: site URL, e.g. https://blog.example.com.'),
45
+ username: z.string().optional().describe('WordPress: username the application password belongs to. Lemmy: username or email.'),
46
+ password: z.string().optional().describe('Lemmy: account password (TOTP-enabled accounts are not supported).'),
47
+ api_key: z.string().optional().describe('Dev.to: API key from Settings, Extensions, DEV Community API Keys.'),
48
+ pat: z.string().optional().describe('Hashnode: personal access token from Account Settings, Developer.'),
49
+ instance: z.string().optional().describe('Lemmy: instance domain, for example lemmy.world.'),
50
+ workspace_id: z.string().optional().describe('Workspace to connect the account into. Workspace-scoped API keys ignore this.'),
51
+ connect_session_id: z.string().optional().describe('Optional connect session ID to mark connected or failed based on the outcome.'),
52
+ }),
53
+
54
+ async execute(
55
+ client: PosterlyClient,
56
+ input: {
57
+ platform: (typeof CREDENTIAL_PLATFORMS)[number];
58
+ workspace_id?: string;
59
+ connect_session_id?: string;
60
+ } & Record<string, string | undefined>,
61
+ ) {
62
+ const allowedFields = PLATFORM_CREDENTIAL_FIELDS[input.platform] || [];
63
+ const credentials: Record<string, string> = {};
64
+ for (const field of allowedFields) {
65
+ const value = input[field];
66
+ if (typeof value === 'string' && value.trim()) {
67
+ credentials[field] = value;
68
+ }
69
+ }
70
+
71
+ const missing = allowedFields.filter((field) => !(field in credentials));
72
+ if (missing.length > 0) {
73
+ return `Missing credential field(s) for ${input.platform}: ${missing.join(', ')}. Call get_connect_link with platform=${input.platform} to see field descriptions.`;
74
+ }
75
+
76
+ const result = await client.connectAccountCredentials({
77
+ platform: input.platform,
78
+ credentials,
79
+ workspace_id: input.workspace_id,
80
+ connect_session_id: input.connect_session_id,
81
+ });
82
+
83
+ const account = result.account || {};
84
+ const lines = [
85
+ `Connected ${input.platform} account: ${account.username || 'unknown'} (id ${account.id})`,
86
+ `Workspace: ${result.workspace_id}`,
87
+ 'Reconnecting the same account updates it in place.',
88
+ ];
89
+ return lines.join('\n');
90
+ },
91
+ };
@@ -5,7 +5,7 @@ import { CONNECT_INPUTS, PLANNED_PLATFORM_IDS } from '../generated/platform-mani
5
5
  export const getConnectLinkTool = {
6
6
  name: 'get_connect_link',
7
7
  description:
8
- 'List dashboard handoff links/readiness for connecting social accounts, or get one platform connection URL. Use connection_url in a logged-in browser. Direct OAuth URLs are intentionally not exposed because provider callbacks rely on browser state.',
8
+ 'List dashboard handoff links/readiness for connecting social accounts, or get one platform connection URL. Use connection_url in a logged-in browser. Direct OAuth URLs are intentionally not exposed because provider callbacks rely on browser state. Credential-based platforms (telegram, bluesky, discord, wordpress, devto, hashnode, lemmy) can instead be connected headlessly with connect_account; this tool shows their credential_fields.',
9
9
  inputSchema: z.object({
10
10
  platform: z
11
11
  .enum(CONNECT_INPUTS)
@@ -1,16 +1,33 @@
1
1
  import { z } from 'zod';
2
- import type { PosterlyClient } from '../lib/api-client.js';
2
+ import type { PosterlyClient, GoogleBusinessLocationReviewSummary } from '../lib/api-client.js';
3
+ import { formatDate } from '../lib/format.js';
4
+
5
+ /** Matches GOOGLE_BUSINESS_REVIEWS_MAX_LIMIT in lib/api-v1-google-business.ts. */
6
+ const MAX_LIMIT = 1000;
3
7
 
4
8
  export const listGoogleBusinessReviewsTool = {
5
9
  name: 'list_google_business_reviews',
6
10
  description:
7
- 'List Google Business Profile reviews for one location/account or every accessible GBP location. Supports rating and unanswered-only filters.',
11
+ 'List Google Business Profile reviews for one location/account or every accessible GBP location. Pages through all reviews Google has (not just the first 50), returns the full untruncated review text with its date, and reports each location\'s total review count. Supports rating and unanswered-only filters plus limit/offset paging.',
8
12
  inputSchema: z.object({
9
13
  workspace_id: z.string().optional().describe('Filter to a workspace ID from whoami.'),
10
14
  account_id: z.number().int().positive().optional().describe('Google Business account ID from list_accounts.'),
11
15
  location_id: z.string().optional().describe('Google Business numeric location id (the location_id/platform_user_id from list_accounts, e.g. "197940849675145390"). NOT the ChIJ... Place ID returned by get_google_business_review_link. Prefer account_id.'),
12
16
  rating: z.number().int().min(1).max(5).optional().describe('Filter to a 1-5 star rating.'),
13
17
  unanswered: z.boolean().optional().describe('Only return reviews without owner replies.'),
18
+ limit: z
19
+ .number()
20
+ .int()
21
+ .min(1)
22
+ .max(MAX_LIMIT)
23
+ .optional()
24
+ .describe(`Maximum reviews to return in this call (default 200, max ${MAX_LIMIT}). Every returned review is listed in full.`),
25
+ offset: z
26
+ .number()
27
+ .int()
28
+ .min(0)
29
+ .optional()
30
+ .describe('Skip this many reviews before returning results. Use with limit to page through a location with more reviews than one call returns.'),
14
31
  }),
15
32
 
16
33
  async execute(
@@ -21,23 +38,99 @@ export const listGoogleBusinessReviewsTool = {
21
38
  location_id?: string;
22
39
  rating?: number;
23
40
  unanswered?: boolean;
41
+ limit?: number;
42
+ offset?: number;
24
43
  },
25
44
  ) {
26
45
  const result = await client.listGoogleBusinessReviews(input);
46
+ const locations = (result.locations || []) as GoogleBusinessLocationReviewSummary[];
47
+ const offset = result.offset ?? 0;
48
+
27
49
  if (result.reviews.length === 0) {
28
- return 'No Google Business reviews found matching your filters.';
50
+ // Distinguish "no reviews exist" from "we couldn't read them" — a silent
51
+ // zero used to be indistinguishable from a broken connection.
52
+ const lines = ['No Google Business reviews found matching your filters.'];
53
+ const locationLines = locations.map((location) => formatLocationLine(location));
54
+ if (locationLines.length > 0) {
55
+ lines.push('', 'Locations checked:', ...locationLines);
56
+ }
57
+ return lines.join('\n');
58
+ }
59
+
60
+ const header = [`Google Business reviews (showing ${result.reviews.length} of ${result.total} matching`];
61
+ if (offset > 0) header.push(`, from offset ${offset}`);
62
+ if (typeof result.total_review_count === 'number') {
63
+ header.push(`; ${result.total_review_count} total on Google`);
64
+ }
65
+ header.push('):');
66
+
67
+ const lines = [header.join('')];
68
+
69
+ for (const location of locations) {
70
+ if (location.error) {
71
+ lines.push(`! ${location.business_name || location.name || location.location_id || 'location'}: ${location.error}`);
72
+ }
29
73
  }
30
74
 
31
- const lines = [`Google Business reviews (${result.reviews.length} of ${result.total}):`];
32
- for (const review of result.reviews.slice(0, 25)) {
75
+ lines.push('');
76
+
77
+ for (const review of result.reviews) {
33
78
  const name = review.reviewer?.displayName || 'Anonymous';
34
79
  const rating = review.starRating ? `${review.starRating} stars` : 'unrated';
35
80
  const location = review.businessName || review.locationName || review.locationId || 'location';
36
- const text = review.comment ? ` - ${review.comment.replace(/\s+/g, ' ').slice(0, 180)}` : '';
37
- const replied = review.reviewReply ? ' replied' : ' unanswered';
38
- lines.push(`- ${name} (${rating}, ${location},${replied})${text}`);
81
+ const date = formatReviewDate(review);
82
+ const replied = review.reviewReply ? 'replied' : 'unanswered';
83
+ lines.push(`- ${name} (${rating}, ${date}, ${location}, ${replied})`);
84
+ // Full review text, no truncation — collapse whitespace only so the
85
+ // bullet list stays readable for multi-paragraph reviews.
86
+ if (review.comment) {
87
+ lines.push(` ${review.comment.replace(/\s+/g, ' ').trim()}`);
88
+ }
89
+ }
90
+
91
+ if (result.has_more) {
92
+ const nextOffset = offset + result.reviews.length;
93
+ lines.push('', `More reviews available — call again with offset: ${nextOffset} to continue.`);
39
94
  }
40
95
 
96
+ lines.push('', 'Locations:', ...locations.map((location) => formatLocationLine(location)));
97
+
41
98
  return lines.join('\n');
42
99
  },
43
100
  };
101
+
102
+ /**
103
+ * When a review was written, plus when it was last edited if that differs.
104
+ * The list is ordered by last activity (updateTime), so showing only the
105
+ * original date would make an edited review look out of order.
106
+ */
107
+ function formatReviewDate(review: { createTime?: string; updateTime?: string }): string {
108
+ const created = review.createTime || review.updateTime;
109
+ const createdLabel = formatDate(created);
110
+ if (!review.updateTime || !review.createTime) return createdLabel;
111
+
112
+ const updatedLabel = formatDate(review.updateTime);
113
+ if (updatedLabel === createdLabel) return createdLabel;
114
+ return `${createdLabel}, edited ${updatedLabel}`;
115
+ }
116
+
117
+ function formatLocationLine(location: GoogleBusinessLocationReviewSummary): string {
118
+ const label = location.business_name || location.name || location.location_id || `account ${location.id}`;
119
+ const parts: string[] = [];
120
+
121
+ if (typeof location.total_review_count === 'number') {
122
+ parts.push(`${location.total_review_count} reviews on Google`);
123
+ } else if (!location.error) {
124
+ parts.push('review count not reported by Google');
125
+ }
126
+
127
+ if (typeof location.average_rating === 'number') {
128
+ parts.push(`${location.average_rating.toFixed(1)} avg`);
129
+ }
130
+
131
+ parts.push(`${location.fetched_review_count} fetched`);
132
+
133
+ if (location.error) parts.push(`ERROR: ${location.error}`);
134
+
135
+ return `- ${label} (account_id ${location.id}): ${parts.join(', ')}`;
136
+ }
@@ -8,6 +8,7 @@
8
8
  "list_accounts",
9
9
  "disconnect_account",
10
10
  "get_connect_link",
11
+ "connect_account",
11
12
  "create_connect_session",
12
13
  "get_connect_session",
13
14
  "create_api_key",
@@ -138,6 +139,7 @@
138
139
  "delete_webhook"
139
140
  ],
140
141
  "idempotentMutationTools": [
142
+ "connect_account",
141
143
  "disconnect_account",
142
144
  "delete_api_key",
143
145
  "cancel_subscription",
@@ -163,6 +165,7 @@
163
165
  "get_signup_session",
164
166
  "get_mcp_status",
165
167
  "disconnect_account",
168
+ "connect_account",
166
169
  "create_connect_session",
167
170
  "get_connect_session",
168
171
  "get_credits",