posterly-mcp-server 0.23.1 → 0.23.2

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
@@ -182,7 +182,13 @@ Authenticated tools require `POSTERLY_API_KEY`:
182
182
  - `test_webhook` (send a signed test delivery after explicit confirmation)
183
183
  - `get_x_posting_quota`
184
184
 
185
- Analytics tools currently support Instagram, LinkedIn, Google Business Profile, Pinterest, and YouTube.
185
+ Analytics tools currently support Instagram, Facebook Pages, LinkedIn, Google Business Profile, Pinterest, YouTube, and Threads.
186
+
187
+ ## Media uploads
188
+
189
+ This npm/stdio server can read local file paths. When `upload_media` receives a larger local file, it automatically requests a signed upload URL and uploads the raw bytes before returning the public media URL.
190
+
191
+ The hosted HTTP MCP endpoint cannot do that for local files by itself because MCP tool calls are JSON-only. On hosted HTTP MCP, `upload_media` is for small base64 uploads up to 5MB decoded. For larger media there, use `upload_media_from_url` for public direct media URLs, or call `create_signed_upload` only from clients that can also `PUT` the raw file bytes to the returned `upload_url`.
186
192
 
187
193
  ## What the brand tools are for
188
194
 
@@ -967,3 +967,16 @@ export declare class PosterlyClient {
967
967
  }>;
968
968
  }
969
969
  export declare function resolvePosterlyBaseUrl(baseUrl?: string): string;
970
+ type PosterlyBaseUrlInspection = {
971
+ valid: boolean;
972
+ secure: boolean;
973
+ protocol?: string;
974
+ host?: string;
975
+ trustedPosterlyHost: boolean;
976
+ localDevelopmentHost: boolean;
977
+ warnings: string[];
978
+ message: string;
979
+ };
980
+ export declare function inspectPosterlyBaseUrl(baseUrl: string): PosterlyBaseUrlInspection;
981
+ export declare function validatePosterlyBaseUrlForKeyUse(baseUrl: string): void;
982
+ export {};
@@ -6,6 +6,9 @@ export class PosterlyClient {
6
6
  constructor(apiKey, baseUrl) {
7
7
  this.apiKey = apiKey || process.env.POSTERLY_API_KEY || '';
8
8
  this.baseUrl = resolvePosterlyBaseUrl(baseUrl);
9
+ if (this.apiKey) {
10
+ validatePosterlyBaseUrlForKeyUse(this.baseUrl);
11
+ }
9
12
  }
10
13
  hasApiKey() {
11
14
  return Boolean(this.apiKey);
@@ -485,6 +488,73 @@ export function resolvePosterlyBaseUrl(baseUrl) {
485
488
  return configured.replace(/\/api\/v1\/?$/, '').replace(/\/$/, '');
486
489
  }
487
490
  }
491
+ const TRUSTED_POSTERLY_HOSTS = new Set(['poster.ly', 'www.poster.ly']);
492
+ function flagEnabled(name) {
493
+ const value = (process.env[name] || '').trim().toLowerCase();
494
+ return value === 'true' || value === '1' || value === 'yes';
495
+ }
496
+ function normalizeHostname(hostname) {
497
+ return hostname.toLowerCase().replace(/^\[|\]$/g, '');
498
+ }
499
+ function isLocalHost(hostname) {
500
+ const normalized = normalizeHostname(hostname);
501
+ return (normalized === 'localhost' ||
502
+ normalized.endsWith('.localhost') ||
503
+ normalized === '127.0.0.1' ||
504
+ normalized === '::1');
505
+ }
506
+ export function inspectPosterlyBaseUrl(baseUrl) {
507
+ try {
508
+ const url = new URL(baseUrl);
509
+ const hostname = normalizeHostname(url.hostname);
510
+ const local = isLocalHost(hostname);
511
+ const trustedPosterlyHost = TRUSTED_POSTERLY_HOSTS.has(hostname);
512
+ const secure = url.protocol === 'https:' || (url.protocol === 'http:' && local);
513
+ const warnings = [];
514
+ if (!trustedPosterlyHost) {
515
+ warnings.push(local ? 'local_development_host' : 'custom_host');
516
+ }
517
+ if (!secure) {
518
+ warnings.push('insecure_non_local_url');
519
+ }
520
+ return {
521
+ valid: true,
522
+ secure,
523
+ protocol: url.protocol.replace(/:$/, ''),
524
+ host: url.host,
525
+ trustedPosterlyHost,
526
+ localDevelopmentHost: local,
527
+ warnings,
528
+ message: secure
529
+ ? 'Base URL is secure for API-key requests.'
530
+ : 'Non-local HTTP URLs are blocked for API-key requests.',
531
+ };
532
+ }
533
+ catch {
534
+ return {
535
+ valid: false,
536
+ secure: false,
537
+ trustedPosterlyHost: false,
538
+ localDevelopmentHost: false,
539
+ warnings: ['invalid_url'],
540
+ message: 'Base URL must include a scheme and host, for example https://www.poster.ly.',
541
+ };
542
+ }
543
+ }
544
+ export function validatePosterlyBaseUrlForKeyUse(baseUrl) {
545
+ const check = inspectPosterlyBaseUrl(baseUrl);
546
+ if (!check.valid) {
547
+ throw new Error(`${check.message} Received: ${baseUrl}`);
548
+ }
549
+ if (!check.secure && !flagEnabled('POSTERLY_ALLOW_INSECURE_URL')) {
550
+ throw new Error(`Refusing to send an API key to ${baseUrl}. Use https, http://localhost for local development, or set POSTERLY_ALLOW_INSECURE_URL=true only for a trusted private development endpoint.`);
551
+ }
552
+ if (!check.trustedPosterlyHost &&
553
+ !check.localDevelopmentHost &&
554
+ !flagEnabled('POSTERLY_ALLOW_CUSTOM_BASE_URL')) {
555
+ throw new Error(`Refusing to send an API key to custom host ${baseUrl}. Set POSTERLY_ALLOW_CUSTOM_BASE_URL=true only for a trusted Posterly deployment.`);
556
+ }
557
+ }
488
558
  function parseJson(text) {
489
559
  try {
490
560
  return text ? JSON.parse(text) : {};
@@ -2,7 +2,7 @@ import { z } from 'zod';
2
2
  const presentationSchema = z.enum(['compact', 'table', 'json']).optional();
3
3
  export const getAccountAnalyticsTool = {
4
4
  name: 'get_account_analytics',
5
- description: 'Get daily analytics snapshots and a period summary for a connected social account. Supports Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, and YouTube. Uses API-provided display_metrics for platform-native dashboard labels and supports presentation: compact, table, or json.',
5
+ description: 'Get daily analytics snapshots and a period summary for a connected social account. Supports Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, YouTube, and Threads. Uses API-provided display_metrics for platform-native dashboard labels and supports presentation: compact, table, or json.',
6
6
  inputSchema: z.object({
7
7
  account_id: z
8
8
  .number()
@@ -2,7 +2,7 @@ import { z } from 'zod';
2
2
  const presentationSchema = z.enum(['compact', 'table', 'json']).optional();
3
3
  export const getPostAnalyticsTool = {
4
4
  name: 'get_post_analytics',
5
- description: 'Get per-post engagement metrics (likes, comments, reach, impressions, saves, shares, plays, clicks, watch time) for a connected social account. Supports Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, and YouTube. Returns the most recent posts first. presentation controls output: compact for Telegram/mobile, table for Markdown clients, json for custom chart/card renderers.',
5
+ description: 'Get per-post engagement metrics (likes, comments, reach, impressions, saves, shares, plays, clicks, watch time) for a connected social account. Supports Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, YouTube, and Threads. Returns the most recent posts first. presentation controls output: compact for Telegram/mobile, table for Markdown clients, json for custom chart/card renderers.',
6
6
  inputSchema: z.object({
7
7
  account_id: z
8
8
  .number()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "posterly-mcp-server",
3
- "version": "0.23.1",
3
+ "version": "0.23.2",
4
4
  "description": "MCP server for posterly: schedule and publish social media posts across 11 platforms from any MCP client (Claude, ChatGPT, Cursor, Windsurf, Cline, and more)",
5
5
  "license": "MIT",
6
6
  "homepage": "https://www.poster.ly/mcp",
@@ -638,6 +638,9 @@ export class PosterlyClient {
638
638
  constructor(apiKey?: string, baseUrl?: string) {
639
639
  this.apiKey = apiKey || process.env.POSTERLY_API_KEY || '';
640
640
  this.baseUrl = resolvePosterlyBaseUrl(baseUrl);
641
+ if (this.apiKey) {
642
+ validatePosterlyBaseUrlForKeyUse(this.baseUrl);
643
+ }
641
644
  }
642
645
 
643
646
  hasApiKey(): boolean {
@@ -1383,6 +1386,101 @@ export function resolvePosterlyBaseUrl(baseUrl?: string): string {
1383
1386
  }
1384
1387
  }
1385
1388
 
1389
+ const TRUSTED_POSTERLY_HOSTS = new Set(['poster.ly', 'www.poster.ly']);
1390
+
1391
+ type PosterlyBaseUrlInspection = {
1392
+ valid: boolean;
1393
+ secure: boolean;
1394
+ protocol?: string;
1395
+ host?: string;
1396
+ trustedPosterlyHost: boolean;
1397
+ localDevelopmentHost: boolean;
1398
+ warnings: string[];
1399
+ message: string;
1400
+ };
1401
+
1402
+ function flagEnabled(name: string): boolean {
1403
+ const value = (process.env[name] || '').trim().toLowerCase();
1404
+ return value === 'true' || value === '1' || value === 'yes';
1405
+ }
1406
+
1407
+ function normalizeHostname(hostname: string): string {
1408
+ return hostname.toLowerCase().replace(/^\[|\]$/g, '');
1409
+ }
1410
+
1411
+ function isLocalHost(hostname: string): boolean {
1412
+ const normalized = normalizeHostname(hostname);
1413
+ return (
1414
+ normalized === 'localhost' ||
1415
+ normalized.endsWith('.localhost') ||
1416
+ normalized === '127.0.0.1' ||
1417
+ normalized === '::1'
1418
+ );
1419
+ }
1420
+
1421
+ export function inspectPosterlyBaseUrl(baseUrl: string): PosterlyBaseUrlInspection {
1422
+ try {
1423
+ const url = new URL(baseUrl);
1424
+ const hostname = normalizeHostname(url.hostname);
1425
+ const local = isLocalHost(hostname);
1426
+ const trustedPosterlyHost = TRUSTED_POSTERLY_HOSTS.has(hostname);
1427
+ const secure = url.protocol === 'https:' || (url.protocol === 'http:' && local);
1428
+ const warnings: string[] = [];
1429
+
1430
+ if (!trustedPosterlyHost) {
1431
+ warnings.push(local ? 'local_development_host' : 'custom_host');
1432
+ }
1433
+ if (!secure) {
1434
+ warnings.push('insecure_non_local_url');
1435
+ }
1436
+
1437
+ return {
1438
+ valid: true,
1439
+ secure,
1440
+ protocol: url.protocol.replace(/:$/, ''),
1441
+ host: url.host,
1442
+ trustedPosterlyHost,
1443
+ localDevelopmentHost: local,
1444
+ warnings,
1445
+ message: secure
1446
+ ? 'Base URL is secure for API-key requests.'
1447
+ : 'Non-local HTTP URLs are blocked for API-key requests.',
1448
+ };
1449
+ } catch {
1450
+ return {
1451
+ valid: false,
1452
+ secure: false,
1453
+ trustedPosterlyHost: false,
1454
+ localDevelopmentHost: false,
1455
+ warnings: ['invalid_url'],
1456
+ message: 'Base URL must include a scheme and host, for example https://www.poster.ly.',
1457
+ };
1458
+ }
1459
+ }
1460
+
1461
+ export function validatePosterlyBaseUrlForKeyUse(baseUrl: string): void {
1462
+ const check = inspectPosterlyBaseUrl(baseUrl);
1463
+ if (!check.valid) {
1464
+ throw new Error(`${check.message} Received: ${baseUrl}`);
1465
+ }
1466
+
1467
+ if (!check.secure && !flagEnabled('POSTERLY_ALLOW_INSECURE_URL')) {
1468
+ throw new Error(
1469
+ `Refusing to send an API key to ${baseUrl}. Use https, http://localhost for local development, or set POSTERLY_ALLOW_INSECURE_URL=true only for a trusted private development endpoint.`
1470
+ );
1471
+ }
1472
+
1473
+ if (
1474
+ !check.trustedPosterlyHost &&
1475
+ !check.localDevelopmentHost &&
1476
+ !flagEnabled('POSTERLY_ALLOW_CUSTOM_BASE_URL')
1477
+ ) {
1478
+ throw new Error(
1479
+ `Refusing to send an API key to custom host ${baseUrl}. Set POSTERLY_ALLOW_CUSTOM_BASE_URL=true only for a trusted Posterly deployment.`
1480
+ );
1481
+ }
1482
+ }
1483
+
1386
1484
  function parseJson(text: string): any {
1387
1485
  try {
1388
1486
  return text ? JSON.parse(text) : {};
@@ -8,7 +8,7 @@ type MetricRow = [string, string];
8
8
  export const getAccountAnalyticsTool = {
9
9
  name: 'get_account_analytics',
10
10
  description:
11
- 'Get daily analytics snapshots and a period summary for a connected social account. Supports Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, and YouTube. Uses API-provided display_metrics for platform-native dashboard labels and supports presentation: compact, table, or json.',
11
+ 'Get daily analytics snapshots and a period summary for a connected social account. Supports Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, YouTube, and Threads. Uses API-provided display_metrics for platform-native dashboard labels and supports presentation: compact, table, or json.',
12
12
  inputSchema: z.object({
13
13
  account_id: z
14
14
  .number()
@@ -7,7 +7,7 @@ type AnalyticsPresentation = z.infer<typeof presentationSchema>;
7
7
  export const getPostAnalyticsTool = {
8
8
  name: 'get_post_analytics',
9
9
  description:
10
- 'Get per-post engagement metrics (likes, comments, reach, impressions, saves, shares, plays, clicks, watch time) for a connected social account. Supports Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, and YouTube. Returns the most recent posts first. presentation controls output: compact for Telegram/mobile, table for Markdown clients, json for custom chart/card renderers.',
10
+ 'Get per-post engagement metrics (likes, comments, reach, impressions, saves, shares, plays, clicks, watch time) for a connected social account. Supports Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, YouTube, and Threads. Returns the most recent posts first. presentation controls output: compact for Telegram/mobile, table for Markdown clients, json for custom chart/card renderers.',
11
11
  inputSchema: z.object({
12
12
  account_id: z
13
13
  .number()