posterly-mcp-server 0.33.2 → 0.33.3

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 CHANGED
@@ -122,7 +122,7 @@ Add the same server definition to your Cursor MCP settings:
122
122
 
123
123
  ## Available tools
124
124
 
125
- `posterly-mcp-server@0.33.2` exposes 77 tools.
125
+ `posterly-mcp-server@0.33.3` exposes 77 tools.
126
126
 
127
127
  Public setup tools work before `POSTERLY_API_KEY` exists:
128
128
 
@@ -568,10 +568,32 @@ export interface GoogleBusinessReview {
568
568
  socialAccountId?: number;
569
569
  account_id?: number;
570
570
  }
571
+ export interface GoogleBusinessLocationReviewSummary {
572
+ id: number;
573
+ location_id: string | null;
574
+ name: string | null;
575
+ business_name: string | null;
576
+ workspace_id: string | null;
577
+ /** Google's own review count for this location, or null when Google didn't report one. */
578
+ total_review_count: number | null;
579
+ average_rating: number | null;
580
+ fetched_review_count: number;
581
+ /** Why this location's reviews are missing or incomplete, else null. */
582
+ error: string | null;
583
+ }
571
584
  export interface GoogleBusinessReviewsResponse {
572
585
  reviews: GoogleBusinessReview[];
586
+ /** Reviews matching the filters across every page, not just the ones returned. */
573
587
  total: number;
574
- locations: Array<Record<string, unknown>>;
588
+ returned?: number;
589
+ limit?: number;
590
+ offset?: number;
591
+ has_more?: boolean;
592
+ /** Reviews fetched from Google before rating/unanswered filters. */
593
+ fetched?: number;
594
+ /** Google's reported review count summed across the selected locations. */
595
+ total_review_count?: number | null;
596
+ locations: Array<GoogleBusinessLocationReviewSummary | Record<string, unknown>>;
575
597
  }
576
598
  export interface GoogleBusinessMediaItem {
577
599
  name: string;
@@ -1051,6 +1073,8 @@ export declare class PosterlyClient {
1051
1073
  location_id?: string;
1052
1074
  rating?: number;
1053
1075
  unanswered?: boolean;
1076
+ limit?: number;
1077
+ offset?: number;
1054
1078
  }): Promise<GoogleBusinessReviewsResponse>;
1055
1079
  getGoogleBusinessReviewLink(params: {
1056
1080
  workspace_id?: string;
@@ -364,6 +364,10 @@ export class PosterlyClient {
364
364
  searchParams.set('rating', String(params.rating));
365
365
  if (params?.unanswered)
366
366
  searchParams.set('unanswered', 'true');
367
+ if (params?.limit)
368
+ searchParams.set('limit', String(params.limit));
369
+ if (params?.offset)
370
+ searchParams.set('offset', String(params.offset));
367
371
  const qs = searchParams.toString();
368
372
  return this.request('GET', `/google-business/reviews${qs ? `?${qs}` : ''}`);
369
373
  }
@@ -1 +1 @@
1
- export declare const POSTERLY_MCP_VERSION = "0.33.2";
1
+ export declare const POSTERLY_MCP_VERSION = "0.33.3";
@@ -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.33.3';
@@ -9,15 +9,21 @@ export declare const listGoogleBusinessReviewsTool: {
9
9
  location_id: z.ZodOptional<z.ZodString>;
10
10
  rating: z.ZodOptional<z.ZodNumber>;
11
11
  unanswered: z.ZodOptional<z.ZodBoolean>;
12
+ limit: z.ZodOptional<z.ZodNumber>;
13
+ offset: z.ZodOptional<z.ZodNumber>;
12
14
  }, "strip", z.ZodTypeAny, {
13
15
  workspace_id?: string | undefined;
14
16
  account_id?: number | undefined;
17
+ limit?: number | undefined;
18
+ offset?: number | undefined;
15
19
  location_id?: string | undefined;
16
20
  rating?: number | undefined;
17
21
  unanswered?: boolean | undefined;
18
22
  }, {
19
23
  workspace_id?: string | undefined;
20
24
  account_id?: number | undefined;
25
+ limit?: number | undefined;
26
+ offset?: number | undefined;
21
27
  location_id?: string | undefined;
22
28
  rating?: number | undefined;
23
29
  unanswered?: boolean | undefined;
@@ -28,5 +34,7 @@ export declare const listGoogleBusinessReviewsTool: {
28
34
  location_id?: string;
29
35
  rating?: number;
30
36
  unanswered?: boolean;
37
+ limit?: number;
38
+ offset?: number;
31
39
  }): Promise<string>;
32
40
  };
@@ -1,28 +1,108 @@
1
1
  import { z } from 'zod';
2
+ import { formatDate } from '../lib/format.js';
3
+ /** Matches GOOGLE_BUSINESS_REVIEWS_MAX_LIMIT in lib/api-v1-google-business.ts. */
4
+ const MAX_LIMIT = 1000;
2
5
  export const listGoogleBusinessReviewsTool = {
3
6
  name: 'list_google_business_reviews',
4
- description: 'List Google Business Profile reviews for one location/account or every accessible GBP location. Supports rating and unanswered-only filters.',
7
+ description: '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.',
5
8
  inputSchema: z.object({
6
9
  workspace_id: z.string().optional().describe('Filter to a workspace ID from whoami.'),
7
10
  account_id: z.number().int().positive().optional().describe('Google Business account ID from list_accounts.'),
8
11
  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.'),
9
12
  rating: z.number().int().min(1).max(5).optional().describe('Filter to a 1-5 star rating.'),
10
13
  unanswered: z.boolean().optional().describe('Only return reviews without owner replies.'),
14
+ limit: z
15
+ .number()
16
+ .int()
17
+ .min(1)
18
+ .max(MAX_LIMIT)
19
+ .optional()
20
+ .describe(`Maximum reviews to return in this call (default 200, max ${MAX_LIMIT}). Every returned review is listed in full.`),
21
+ offset: z
22
+ .number()
23
+ .int()
24
+ .min(0)
25
+ .optional()
26
+ .describe('Skip this many reviews before returning results. Use with limit to page through a location with more reviews than one call returns.'),
11
27
  }),
12
28
  async execute(client, input) {
13
29
  const result = await client.listGoogleBusinessReviews(input);
30
+ const locations = (result.locations || []);
31
+ const offset = result.offset ?? 0;
14
32
  if (result.reviews.length === 0) {
15
- return 'No Google Business reviews found matching your filters.';
33
+ // Distinguish "no reviews exist" from "we couldn't read them" — a silent
34
+ // zero used to be indistinguishable from a broken connection.
35
+ const lines = ['No Google Business reviews found matching your filters.'];
36
+ const locationLines = locations.map((location) => formatLocationLine(location));
37
+ if (locationLines.length > 0) {
38
+ lines.push('', 'Locations checked:', ...locationLines);
39
+ }
40
+ return lines.join('\n');
16
41
  }
17
- const lines = [`Google Business reviews (${result.reviews.length} of ${result.total}):`];
18
- for (const review of result.reviews.slice(0, 25)) {
42
+ const header = [`Google Business reviews (showing ${result.reviews.length} of ${result.total} matching`];
43
+ if (offset > 0)
44
+ header.push(`, from offset ${offset}`);
45
+ if (typeof result.total_review_count === 'number') {
46
+ header.push(`; ${result.total_review_count} total on Google`);
47
+ }
48
+ header.push('):');
49
+ const lines = [header.join('')];
50
+ for (const location of locations) {
51
+ if (location.error) {
52
+ lines.push(`! ${location.business_name || location.name || location.location_id || 'location'}: ${location.error}`);
53
+ }
54
+ }
55
+ lines.push('');
56
+ for (const review of result.reviews) {
19
57
  const name = review.reviewer?.displayName || 'Anonymous';
20
58
  const rating = review.starRating ? `${review.starRating} stars` : 'unrated';
21
59
  const location = review.businessName || review.locationName || review.locationId || 'location';
22
- const text = review.comment ? ` - ${review.comment.replace(/\s+/g, ' ').slice(0, 180)}` : '';
23
- const replied = review.reviewReply ? ' replied' : ' unanswered';
24
- lines.push(`- ${name} (${rating}, ${location},${replied})${text}`);
60
+ const date = formatReviewDate(review);
61
+ const replied = review.reviewReply ? 'replied' : 'unanswered';
62
+ lines.push(`- ${name} (${rating}, ${date}, ${location}, ${replied})`);
63
+ // Full review text, no truncation — collapse whitespace only so the
64
+ // bullet list stays readable for multi-paragraph reviews.
65
+ if (review.comment) {
66
+ lines.push(` ${review.comment.replace(/\s+/g, ' ').trim()}`);
67
+ }
68
+ }
69
+ if (result.has_more) {
70
+ const nextOffset = offset + result.reviews.length;
71
+ lines.push('', `More reviews available — call again with offset: ${nextOffset} to continue.`);
25
72
  }
73
+ lines.push('', 'Locations:', ...locations.map((location) => formatLocationLine(location)));
26
74
  return lines.join('\n');
27
75
  },
28
76
  };
77
+ /**
78
+ * When a review was written, plus when it was last edited if that differs.
79
+ * The list is ordered by last activity (updateTime), so showing only the
80
+ * original date would make an edited review look out of order.
81
+ */
82
+ function formatReviewDate(review) {
83
+ const created = review.createTime || review.updateTime;
84
+ const createdLabel = formatDate(created);
85
+ if (!review.updateTime || !review.createTime)
86
+ return createdLabel;
87
+ const updatedLabel = formatDate(review.updateTime);
88
+ if (updatedLabel === createdLabel)
89
+ return createdLabel;
90
+ return `${createdLabel}, edited ${updatedLabel}`;
91
+ }
92
+ function formatLocationLine(location) {
93
+ const label = location.business_name || location.name || location.location_id || `account ${location.id}`;
94
+ const parts = [];
95
+ if (typeof location.total_review_count === 'number') {
96
+ parts.push(`${location.total_review_count} reviews on Google`);
97
+ }
98
+ else if (!location.error) {
99
+ parts.push('review count not reported by Google');
100
+ }
101
+ if (typeof location.average_rating === 'number') {
102
+ parts.push(`${location.average_rating.toFixed(1)} avg`);
103
+ }
104
+ parts.push(`${location.fetched_review_count} fetched`);
105
+ if (location.error)
106
+ parts.push(`ERROR: ${location.error}`);
107
+ return `- ${label} (account_id ${location.id}): ${parts.join(', ')}`;
108
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "posterly-mcp-server",
3
- "version": "0.33.2",
3
+ "version": "0.33.3",
4
4
  "mcpName": "io.github.awpthorp/posterly",
5
5
  "description": "MCP server for posterly: schedule and publish social media posts across 18 platforms from any MCP client (Claude, ChatGPT, Cursor, Windsurf, Cline, and more)",
6
6
  "license": "MIT",
package/server.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "io.github.awpthorp/posterly",
4
4
  "title": "posterly",
5
5
  "description": "Validate, schedule, publish, and analyze social content across 18 platforms with posterly.",
6
- "version": "0.33.2",
6
+ "version": "0.33.3",
7
7
  "websiteUrl": "https://www.poster.ly/mcp",
8
8
  "repository": {
9
9
  "url": "https://github.com/awpthorp/posterly",
@@ -14,7 +14,7 @@
14
14
  {
15
15
  "registryType": "npm",
16
16
  "identifier": "posterly-mcp-server",
17
- "version": "0.33.2",
17
+ "version": "0.33.3",
18
18
  "transport": {
19
19
  "type": "stdio"
20
20
  },
@@ -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 {
@@ -1375,6 +1398,8 @@ export class PosterlyClient {
1375
1398
  location_id?: string;
1376
1399
  rating?: number;
1377
1400
  unanswered?: boolean;
1401
+ limit?: number;
1402
+ offset?: number;
1378
1403
  }): Promise<GoogleBusinessReviewsResponse> {
1379
1404
  const searchParams = new URLSearchParams();
1380
1405
  if (params?.workspace_id) searchParams.set('workspace_id', params.workspace_id);
@@ -1382,6 +1407,8 @@ export class PosterlyClient {
1382
1407
  if (params?.location_id) searchParams.set('location_id', params.location_id);
1383
1408
  if (params?.rating) searchParams.set('rating', String(params.rating));
1384
1409
  if (params?.unanswered) searchParams.set('unanswered', 'true');
1410
+ if (params?.limit) searchParams.set('limit', String(params.limit));
1411
+ if (params?.offset) searchParams.set('offset', String(params.offset));
1385
1412
  const qs = searchParams.toString();
1386
1413
  return this.request('GET', `/google-business/reviews${qs ? `?${qs}` : ''}`);
1387
1414
  }
@@ -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.33.3';
@@ -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
+ }