@socialneuron/mcp-server 1.5.0 → 1.5.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/dist/http.js CHANGED
@@ -853,6 +853,41 @@ function checkRateLimit(category, key) {
853
853
 
854
854
  // src/tools/ideation.ts
855
855
  init_supabase();
856
+
857
+ // src/lib/tool-errors.ts
858
+ function formatToolError(rawMessage) {
859
+ const msg = rawMessage.toLowerCase();
860
+ if (msg.includes("rate limit") || msg.includes("too many requests")) {
861
+ return `${rawMessage} Reduce request frequency or wait before retrying.`;
862
+ }
863
+ if (msg.includes("insufficient credit") || msg.includes("budget") || msg.includes("spending cap")) {
864
+ return `${rawMessage} Call get_credit_balance to check remaining credits. Consider a cheaper model or wait for monthly refresh.`;
865
+ }
866
+ if (msg.includes("oauth") || msg.includes("token expired") || msg.includes("not connected") || msg.includes("reconnect")) {
867
+ return `${rawMessage} Call list_connected_accounts to check status. User may need to reconnect at socialneuron.com/settings/connections.`;
868
+ }
869
+ if (msg.includes("generation failed") || msg.includes("failed to start") || msg.includes("no job id") || msg.includes("could not be parsed")) {
870
+ return `${rawMessage} Try simplifying the prompt, using a different model, or check credits with get_credit_balance.`;
871
+ }
872
+ if (msg.includes("not found") || msg.includes("no ") && msg.includes(" found")) {
873
+ return `${rawMessage} Verify the ID is correct \u2014 use the corresponding list tool to find valid IDs.`;
874
+ }
875
+ if (msg.includes("not accessible") || msg.includes("unauthorized") || msg.includes("permission")) {
876
+ return `${rawMessage} Check API key scopes with get_credit_balance. A higher-tier plan may be required.`;
877
+ }
878
+ if (msg.includes("ssrf") || msg.includes("url blocked")) {
879
+ return `${rawMessage} The URL was blocked for security. Use a publicly accessible HTTPS URL.`;
880
+ }
881
+ if (msg.includes("failed to schedule") || msg.includes("scheduling failed")) {
882
+ return `${rawMessage} Verify platform OAuth is active with list_connected_accounts, then retry.`;
883
+ }
884
+ if (msg.includes("no posts") || msg.includes("plan") && msg.includes("has no")) {
885
+ return `${rawMessage} Generate a plan with plan_content_week first, then save with save_content_plan.`;
886
+ }
887
+ return rawMessage;
888
+ }
889
+
890
+ // src/tools/ideation.ts
856
891
  function registerIdeationTools(server) {
857
892
  server.tool(
858
893
  "generate_content",
@@ -886,6 +921,13 @@ function registerIdeationTools(server) {
886
921
  "Project ID to auto-load brand profile and performance context for prompt enrichment."
887
922
  )
888
923
  },
924
+ {
925
+ title: "Generate Content",
926
+ readOnlyHint: false,
927
+ destructiveHint: false,
928
+ idempotentHint: false,
929
+ openWorldHint: true
930
+ },
889
931
  async ({
890
932
  prompt,
891
933
  content_type,
@@ -1019,7 +1061,7 @@ Content Type: ${content_type}`;
1019
1061
  content: [
1020
1062
  {
1021
1063
  type: "text",
1022
- text: `Content generation failed: ${error}`
1064
+ text: formatToolError(`Content generation failed: ${error}`)
1023
1065
  }
1024
1066
  ],
1025
1067
  isError: true
@@ -1049,6 +1091,13 @@ Content Type: ${content_type}`;
1049
1091
  ),
1050
1092
  force_refresh: z.boolean().optional().describe("Skip the server-side cache and fetch fresh data.")
1051
1093
  },
1094
+ {
1095
+ title: "Fetch Trends",
1096
+ readOnlyHint: true,
1097
+ destructiveHint: false,
1098
+ idempotentHint: false,
1099
+ openWorldHint: true
1100
+ },
1052
1101
  async ({ source, category, niche, url, force_refresh }) => {
1053
1102
  if ((source === "rss" || source === "url") && !url) {
1054
1103
  return {
@@ -1077,7 +1126,7 @@ Content Type: ${content_type}`;
1077
1126
  content: [
1078
1127
  {
1079
1128
  type: "text",
1080
- text: `Failed to fetch trends: ${error}`
1129
+ text: formatToolError(`Failed to fetch trends: ${error}`)
1081
1130
  }
1082
1131
  ],
1083
1132
  isError: true
@@ -1153,6 +1202,13 @@ Content Type: ${content_type}`;
1153
1202
  "Optional project ID to load platform voice overrides from brand profile."
1154
1203
  )
1155
1204
  },
1205
+ {
1206
+ title: "Adapt Content",
1207
+ readOnlyHint: false,
1208
+ destructiveHint: false,
1209
+ idempotentHint: false,
1210
+ openWorldHint: true
1211
+ },
1156
1212
  async ({
1157
1213
  content,
1158
1214
  source_platform,
@@ -1238,7 +1294,7 @@ ${content}`,
1238
1294
  content: [
1239
1295
  {
1240
1296
  type: "text",
1241
- text: `Content adaptation failed: ${error}`
1297
+ text: formatToolError(`Content adaptation failed: ${error}`)
1242
1298
  }
1243
1299
  ],
1244
1300
  isError: true
@@ -1312,7 +1368,7 @@ function sanitizeDbError(error) {
1312
1368
  init_request_context();
1313
1369
 
1314
1370
  // src/lib/version.ts
1315
- var MCP_VERSION = "1.5.0";
1371
+ var MCP_VERSION = "1.5.2";
1316
1372
 
1317
1373
  // src/tools/content.ts
1318
1374
  var MAX_CREDITS_PER_RUN = Math.max(
@@ -1454,6 +1510,13 @@ function registerContentTools(server) {
1454
1510
  ),
1455
1511
  response_format: z2.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
1456
1512
  },
1513
+ {
1514
+ title: "Generate Video",
1515
+ readOnlyHint: false,
1516
+ destructiveHint: false,
1517
+ idempotentHint: false,
1518
+ openWorldHint: true
1519
+ },
1457
1520
  async ({
1458
1521
  prompt,
1459
1522
  model,
@@ -1545,7 +1608,7 @@ function registerContentTools(server) {
1545
1608
  content: [
1546
1609
  {
1547
1610
  type: "text",
1548
- text: `Video generation failed to start: ${error}`
1611
+ text: formatToolError(`Video generation failed to start: ${error}`)
1549
1612
  }
1550
1613
  ],
1551
1614
  isError: true
@@ -1562,7 +1625,7 @@ function registerContentTools(server) {
1562
1625
  content: [
1563
1626
  {
1564
1627
  type: "text",
1565
- text: "Video generation failed: no job ID returned."
1628
+ text: formatToolError("Video generation failed: no job ID returned.")
1566
1629
  }
1567
1630
  ],
1568
1631
  isError: true
@@ -1652,6 +1715,13 @@ function registerContentTools(server) {
1652
1715
  ),
1653
1716
  response_format: z2.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
1654
1717
  },
1718
+ {
1719
+ title: "Generate Image",
1720
+ readOnlyHint: false,
1721
+ destructiveHint: false,
1722
+ idempotentHint: false,
1723
+ openWorldHint: true
1724
+ },
1655
1725
  async ({ prompt, model, aspect_ratio, image_url, response_format }) => {
1656
1726
  const format = response_format ?? "text";
1657
1727
  const startedAt = Date.now();
@@ -1731,7 +1801,7 @@ function registerContentTools(server) {
1731
1801
  content: [
1732
1802
  {
1733
1803
  type: "text",
1734
- text: `Image generation failed to start: ${error}`
1804
+ text: formatToolError(`Image generation failed to start: ${error}`)
1735
1805
  }
1736
1806
  ],
1737
1807
  isError: true
@@ -1748,7 +1818,7 @@ function registerContentTools(server) {
1748
1818
  content: [
1749
1819
  {
1750
1820
  type: "text",
1751
- text: "Image generation failed: no job ID returned."
1821
+ text: formatToolError("Image generation failed: no job ID returned.")
1752
1822
  }
1753
1823
  ],
1754
1824
  isError: true
@@ -1815,6 +1885,13 @@ function registerContentTools(server) {
1815
1885
  ),
1816
1886
  response_format: z2.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
1817
1887
  },
1888
+ {
1889
+ title: "Check Job Status",
1890
+ readOnlyHint: true,
1891
+ destructiveHint: false,
1892
+ idempotentHint: true,
1893
+ openWorldHint: true
1894
+ },
1818
1895
  async ({ job_id, response_format }) => {
1819
1896
  const format = response_format ?? "text";
1820
1897
  const startedAt = Date.now();
@@ -1865,7 +1942,7 @@ function registerContentTools(server) {
1865
1942
  content: [
1866
1943
  {
1867
1944
  type: "text",
1868
- text: `Failed to look up job: ${sanitizeDbError(jobError)}`
1945
+ text: formatToolError(`Failed to look up job: ${sanitizeDbError(jobError)}`)
1869
1946
  }
1870
1947
  ],
1871
1948
  isError: true
@@ -1882,7 +1959,7 @@ function registerContentTools(server) {
1882
1959
  content: [
1883
1960
  {
1884
1961
  type: "text",
1885
- text: `No job found with ID "${job_id}". The ID may be incorrect or the job has expired.`
1962
+ text: formatToolError(`No job found with ID "${job_id}". The ID may be incorrect or the job has expired.`)
1886
1963
  }
1887
1964
  ],
1888
1965
  isError: true
@@ -2012,6 +2089,13 @@ function registerContentTools(server) {
2012
2089
  "Response format. Defaults to json for structured storyboard data."
2013
2090
  )
2014
2091
  },
2092
+ {
2093
+ title: "Create Storyboard",
2094
+ readOnlyHint: false,
2095
+ destructiveHint: false,
2096
+ idempotentHint: false,
2097
+ openWorldHint: true
2098
+ },
2015
2099
  async ({
2016
2100
  concept,
2017
2101
  brand_context,
@@ -2123,7 +2207,7 @@ Return ONLY valid JSON in this exact format:
2123
2207
  content: [
2124
2208
  {
2125
2209
  type: "text",
2126
- text: `Storyboard generation failed: ${error}`
2210
+ text: formatToolError(`Storyboard generation failed: ${error}`)
2127
2211
  }
2128
2212
  ],
2129
2213
  isError: true
@@ -2192,6 +2276,13 @@ Return ONLY valid JSON in this exact format:
2192
2276
  speed: z2.number().min(0.5).max(2).optional().describe("Speech speed multiplier. 1.0 is normal. Defaults to 1.0."),
2193
2277
  response_format: z2.enum(["text", "json"]).optional().describe("Response format. Defaults to text.")
2194
2278
  },
2279
+ {
2280
+ title: "Generate Voiceover",
2281
+ readOnlyHint: false,
2282
+ destructiveHint: false,
2283
+ idempotentHint: false,
2284
+ openWorldHint: true
2285
+ },
2195
2286
  async ({ text, voice, speed, response_format }) => {
2196
2287
  const format = response_format ?? "text";
2197
2288
  const startedAt = Date.now();
@@ -2251,7 +2342,7 @@ Return ONLY valid JSON in this exact format:
2251
2342
  content: [
2252
2343
  {
2253
2344
  type: "text",
2254
- text: `Voiceover generation failed: ${error}`
2345
+ text: formatToolError(`Voiceover generation failed: ${error}`)
2255
2346
  }
2256
2347
  ],
2257
2348
  isError: true
@@ -2268,7 +2359,7 @@ Return ONLY valid JSON in this exact format:
2268
2359
  content: [
2269
2360
  {
2270
2361
  type: "text",
2271
- text: "Voiceover generation failed: no audio URL returned."
2362
+ text: formatToolError("Voiceover generation failed: no audio URL returned.")
2272
2363
  }
2273
2364
  ],
2274
2365
  isError: true
@@ -2350,6 +2441,13 @@ Return ONLY valid JSON in this exact format:
2350
2441
  project_id: z2.string().optional().describe("Project ID to associate the carousel with."),
2351
2442
  response_format: z2.enum(["text", "json"]).optional().describe("Response format. Defaults to json.")
2352
2443
  },
2444
+ {
2445
+ title: "Generate Carousel",
2446
+ readOnlyHint: false,
2447
+ destructiveHint: false,
2448
+ idempotentHint: false,
2449
+ openWorldHint: true
2450
+ },
2353
2451
  async ({
2354
2452
  topic,
2355
2453
  template_id,
@@ -2424,7 +2522,7 @@ Return ONLY valid JSON in this exact format:
2424
2522
  content: [
2425
2523
  {
2426
2524
  type: "text",
2427
- text: `Carousel generation failed: ${error}`
2525
+ text: formatToolError(`Carousel generation failed: ${error}`)
2428
2526
  }
2429
2527
  ],
2430
2528
  isError: true
@@ -2684,6 +2782,13 @@ function registerDistributionTools(server) {
2684
2782
  'If true, appends "Created with Social Neuron" to the caption. Default: false.'
2685
2783
  )
2686
2784
  },
2785
+ {
2786
+ title: "Schedule Post",
2787
+ readOnlyHint: false,
2788
+ destructiveHint: false,
2789
+ idempotentHint: false,
2790
+ openWorldHint: true
2791
+ },
2687
2792
  async ({
2688
2793
  media_url,
2689
2794
  media_urls,
@@ -2764,7 +2869,7 @@ Created with Social Neuron`;
2764
2869
  content: [
2765
2870
  {
2766
2871
  type: "text",
2767
- text: `Failed to schedule post: ${error}`
2872
+ text: formatToolError(`Failed to schedule post: ${error}`)
2768
2873
  }
2769
2874
  ],
2770
2875
  isError: true
@@ -2835,6 +2940,13 @@ Created with Social Neuron`;
2835
2940
  {
2836
2941
  response_format: z3.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
2837
2942
  },
2943
+ {
2944
+ title: "List Connected Accounts",
2945
+ readOnlyHint: true,
2946
+ destructiveHint: false,
2947
+ idempotentHint: true,
2948
+ openWorldHint: false
2949
+ },
2838
2950
  async ({ response_format }) => {
2839
2951
  const format = response_format ?? "text";
2840
2952
  const supabase = getSupabaseClient();
@@ -2845,7 +2957,7 @@ Created with Social Neuron`;
2845
2957
  content: [
2846
2958
  {
2847
2959
  type: "text",
2848
- text: `Failed to list connected accounts: ${sanitizeDbError(error)}`
2960
+ text: formatToolError(`Failed to list connected accounts: ${sanitizeDbError(error)}`)
2849
2961
  }
2850
2962
  ],
2851
2963
  isError: true
@@ -2913,6 +3025,13 @@ Created with Social Neuron`;
2913
3025
  limit: z3.number().min(1).max(50).optional().describe("Maximum number of posts to return. Defaults to 20."),
2914
3026
  response_format: z3.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
2915
3027
  },
3028
+ {
3029
+ title: "List Recent Posts",
3030
+ readOnlyHint: true,
3031
+ destructiveHint: false,
3032
+ idempotentHint: true,
3033
+ openWorldHint: false
3034
+ },
2916
3035
  async ({ platform: platform2, status, days, limit, response_format }) => {
2917
3036
  const format = response_format ?? "text";
2918
3037
  const supabase = getSupabaseClient();
@@ -2937,7 +3056,7 @@ Created with Social Neuron`;
2937
3056
  content: [
2938
3057
  {
2939
3058
  type: "text",
2940
- text: `Failed to list posts: ${sanitizeDbError(error)}`
3059
+ text: formatToolError(`Failed to list posts: ${sanitizeDbError(error)}`)
2941
3060
  }
2942
3061
  ],
2943
3062
  isError: true
@@ -3012,7 +3131,7 @@ Created with Social Neuron`;
3012
3131
  };
3013
3132
  server.tool(
3014
3133
  "find_next_slots",
3015
- "Find optimal posting time slots based on best posting times and existing schedule. Returns non-conflicting slots sorted by engagement score.",
3134
+ "Find the next available posting time slots that avoid conflicts with already-scheduled posts. Uses engagement data from get_best_posting_times to rank slots. Call this before schedule_content_plan to pick optimal, non-overlapping times for each post.",
3016
3135
  {
3017
3136
  platforms: z3.array(
3018
3137
  z3.enum([
@@ -3025,11 +3144,18 @@ Created with Social Neuron`;
3025
3144
  "threads",
3026
3145
  "bluesky"
3027
3146
  ])
3028
- ).min(1),
3147
+ ).min(1).describe("Platforms to find posting slots for."),
3029
3148
  count: z3.number().min(1).max(20).default(7).describe("Number of slots to find"),
3030
3149
  start_after: z3.string().optional().describe("ISO datetime, defaults to now"),
3031
3150
  min_gap_hours: z3.number().min(1).max(24).default(4).describe("Minimum gap between posts on same platform"),
3032
- response_format: z3.enum(["text", "json"]).default("text")
3151
+ response_format: z3.enum(["text", "json"]).default("text").describe("Response format. Defaults to text.")
3152
+ },
3153
+ {
3154
+ title: "Find Next Posting Slots",
3155
+ readOnlyHint: true,
3156
+ destructiveHint: false,
3157
+ idempotentHint: true,
3158
+ openWorldHint: false
3033
3159
  },
3034
3160
  async ({
3035
3161
  platforms,
@@ -3135,7 +3261,7 @@ Created with Social Neuron`;
3135
3261
  });
3136
3262
  return {
3137
3263
  content: [
3138
- { type: "text", text: `Failed to find slots: ${message}` }
3264
+ { type: "text", text: formatToolError(`Failed to find slots: ${message}`) }
3139
3265
  ],
3140
3266
  isError: true
3141
3267
  };
@@ -3149,20 +3275,20 @@ Created with Social Neuron`;
3149
3275
  plan: z3.object({
3150
3276
  posts: z3.array(
3151
3277
  z3.object({
3152
- id: z3.string(),
3153
- caption: z3.string(),
3154
- platform: z3.string(),
3155
- title: z3.string().optional(),
3156
- media_url: z3.string().optional(),
3157
- schedule_at: z3.string().optional(),
3158
- hashtags: z3.array(z3.string()).optional()
3278
+ id: z3.string().describe("Unique post identifier from the content plan."),
3279
+ caption: z3.string().describe("Post caption/body text."),
3280
+ platform: z3.string().describe("Target platform name (e.g. instagram, youtube)."),
3281
+ title: z3.string().optional().describe("Post title, required for YouTube."),
3282
+ media_url: z3.string().optional().describe("Public or R2 signed URL for the post media."),
3283
+ schedule_at: z3.string().optional().describe("ISO 8601 UTC datetime to publish (e.g. 2026-03-20T14:00:00Z)."),
3284
+ hashtags: z3.array(z3.string()).optional().describe("Hashtags to append to the caption.")
3159
3285
  })
3160
3286
  )
3161
- }).passthrough().optional(),
3287
+ }).passthrough().optional().describe("Inline content plan object with a posts array. Provide this or plan_id."),
3162
3288
  plan_id: z3.string().uuid().optional().describe("Persisted content plan ID from content_plans table"),
3163
3289
  auto_slot: z3.boolean().default(true).describe("Auto-assign time slots for posts without schedule_at"),
3164
3290
  dry_run: z3.boolean().default(false).describe("Preview without actually scheduling"),
3165
- response_format: z3.enum(["text", "json"]).default("text"),
3291
+ response_format: z3.enum(["text", "json"]).default("text").describe("Response format. Defaults to text."),
3166
3292
  enforce_quality: z3.boolean().default(true).describe(
3167
3293
  "When true, block scheduling for posts that fail quality checks."
3168
3294
  ),
@@ -3172,6 +3298,13 @@ Created with Social Neuron`;
3172
3298
  batch_size: z3.number().int().min(1).max(10).default(4).describe("Concurrent schedule calls per platform batch."),
3173
3299
  idempotency_seed: z3.string().max(128).optional().describe("Optional stable seed used when building idempotency keys.")
3174
3300
  },
3301
+ {
3302
+ title: "Schedule Content Plan",
3303
+ readOnlyHint: false,
3304
+ destructiveHint: false,
3305
+ idempotentHint: false,
3306
+ openWorldHint: true
3307
+ },
3175
3308
  async ({
3176
3309
  plan,
3177
3310
  plan_id,
@@ -3198,7 +3331,7 @@ Created with Social Neuron`;
3198
3331
  content: [
3199
3332
  {
3200
3333
  type: "text",
3201
- text: `Failed to load content plan: ${sanitizeDbError(storedError)}`
3334
+ text: formatToolError(`Failed to load content plan: ${sanitizeDbError(storedError)}`)
3202
3335
  }
3203
3336
  ],
3204
3337
  isError: true
@@ -3209,7 +3342,7 @@ Created with Social Neuron`;
3209
3342
  content: [
3210
3343
  {
3211
3344
  type: "text",
3212
- text: `No content plan found for plan_id=${plan_id}`
3345
+ text: formatToolError(`No content plan found for plan_id=${plan_id}`)
3213
3346
  }
3214
3347
  ],
3215
3348
  isError: true
@@ -3224,7 +3357,7 @@ Created with Social Neuron`;
3224
3357
  content: [
3225
3358
  {
3226
3359
  type: "text",
3227
- text: `Stored plan ${plan_id} has no posts array.`
3360
+ text: formatToolError(`Stored plan ${plan_id} has no posts array.`)
3228
3361
  }
3229
3362
  ],
3230
3363
  isError: true
@@ -3263,7 +3396,7 @@ Created with Social Neuron`;
3263
3396
  content: [
3264
3397
  {
3265
3398
  type: "text",
3266
- text: `Failed to load plan approvals: ${sanitizeDbError(approvalsError)}`
3399
+ text: formatToolError(`Failed to load plan approvals: ${sanitizeDbError(approvalsError)}`)
3267
3400
  }
3268
3401
  ],
3269
3402
  isError: true
@@ -3698,7 +3831,7 @@ Created with Social Neuron`;
3698
3831
  content: [
3699
3832
  {
3700
3833
  type: "text",
3701
- text: `Batch scheduling failed: ${message}`
3834
+ text: formatToolError(`Batch scheduling failed: ${message}`)
3702
3835
  }
3703
3836
  ],
3704
3837
  isError: true
@@ -3742,6 +3875,7 @@ function registerAnalyticsTools(server) {
3742
3875
  limit: z4.number().min(1).max(100).optional().describe("Maximum number of posts to return. Defaults to 20."),
3743
3876
  response_format: z4.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
3744
3877
  },
3878
+ { title: "Fetch Analytics", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
3745
3879
  async ({ platform: platform2, days, content_id, limit, response_format }) => {
3746
3880
  const format = response_format ?? "text";
3747
3881
  const supabase = getSupabaseClient();
@@ -3788,7 +3922,7 @@ function registerAnalyticsTools(server) {
3788
3922
  content: [
3789
3923
  {
3790
3924
  type: "text",
3791
- text: `Failed to fetch user-scoped posts: ${sanitizeDbError(postsError)}`
3925
+ text: formatToolError(`Failed to fetch user-scoped posts: ${sanitizeDbError(postsError)}`)
3792
3926
  }
3793
3927
  ],
3794
3928
  isError: true
@@ -3835,7 +3969,7 @@ function registerAnalyticsTools(server) {
3835
3969
  content: [
3836
3970
  {
3837
3971
  type: "text",
3838
- text: `Failed to fetch analytics: ${sanitizeDbError(simpleError)}`
3972
+ text: formatToolError(`Failed to fetch analytics: ${sanitizeDbError(simpleError)}`)
3839
3973
  }
3840
3974
  ],
3841
3975
  isError: true
@@ -3919,6 +4053,13 @@ function registerAnalyticsTools(server) {
3919
4053
  {
3920
4054
  response_format: z4.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
3921
4055
  },
4056
+ {
4057
+ title: "Refresh Platform Analytics",
4058
+ readOnlyHint: false,
4059
+ destructiveHint: false,
4060
+ idempotentHint: false,
4061
+ openWorldHint: true
4062
+ },
3922
4063
  async ({ response_format }) => {
3923
4064
  const format = response_format ?? "text";
3924
4065
  const startedAt = Date.now();
@@ -3958,7 +4099,7 @@ function registerAnalyticsTools(server) {
3958
4099
  content: [
3959
4100
  {
3960
4101
  type: "text",
3961
- text: `Error refreshing analytics: ${error}`
4102
+ text: formatToolError(`Error refreshing analytics: ${error}`)
3962
4103
  }
3963
4104
  ],
3964
4105
  isError: true
@@ -4318,6 +4459,13 @@ function registerBrandTools(server) {
4318
4459
  ),
4319
4460
  response_format: z5.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
4320
4461
  },
4462
+ {
4463
+ title: "Extract Brand",
4464
+ readOnlyHint: true,
4465
+ destructiveHint: false,
4466
+ idempotentHint: true,
4467
+ openWorldHint: true
4468
+ },
4321
4469
  async ({ url, response_format }) => {
4322
4470
  const ssrfCheck = await validateUrlForSSRF(url);
4323
4471
  if (!ssrfCheck.isValid) {
@@ -4338,7 +4486,7 @@ function registerBrandTools(server) {
4338
4486
  content: [
4339
4487
  {
4340
4488
  type: "text",
4341
- text: `Brand extraction failed: ${error}`
4489
+ text: formatToolError(`Brand extraction failed: ${error}`)
4342
4490
  }
4343
4491
  ],
4344
4492
  isError: true
@@ -4401,6 +4549,13 @@ function registerBrandTools(server) {
4401
4549
  project_id: z5.string().uuid().optional().describe("Project ID. Defaults to active project context."),
4402
4550
  response_format: z5.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
4403
4551
  },
4552
+ {
4553
+ title: "Get Brand Profile",
4554
+ readOnlyHint: true,
4555
+ destructiveHint: false,
4556
+ idempotentHint: true,
4557
+ openWorldHint: false
4558
+ },
4404
4559
  async ({ project_id, response_format }) => {
4405
4560
  const supabase = getSupabaseClient();
4406
4561
  const userId = await getDefaultUserId();
@@ -4441,7 +4596,7 @@ function registerBrandTools(server) {
4441
4596
  content: [
4442
4597
  {
4443
4598
  type: "text",
4444
- text: `Failed to load brand profile: ${sanitizeDbError(error)}`
4599
+ text: formatToolError(`Failed to load brand profile: ${sanitizeDbError(error)}`)
4445
4600
  }
4446
4601
  ],
4447
4602
  isError: true
@@ -4498,8 +4653,17 @@ function registerBrandTools(server) {
4498
4653
  "product_showcase"
4499
4654
  ]).optional().describe("Extraction method metadata."),
4500
4655
  overall_confidence: z5.number().min(0).max(1).optional().describe("Optional overall confidence score in range 0..1."),
4501
- extraction_metadata: z5.record(z5.string(), z5.unknown()).optional(),
4502
- response_format: z5.enum(["text", "json"]).optional()
4656
+ extraction_metadata: z5.record(z5.string(), z5.unknown()).optional().describe(
4657
+ "Arbitrary key-value metadata about the extraction process."
4658
+ ),
4659
+ response_format: z5.enum(["text", "json"]).optional().describe("Response format. Defaults to text.")
4660
+ },
4661
+ {
4662
+ title: "Save Brand Profile",
4663
+ readOnlyHint: false,
4664
+ destructiveHint: false,
4665
+ idempotentHint: false,
4666
+ openWorldHint: false
4503
4667
  },
4504
4668
  async ({
4505
4669
  project_id,
@@ -4563,7 +4727,7 @@ function registerBrandTools(server) {
4563
4727
  content: [
4564
4728
  {
4565
4729
  type: "text",
4566
- text: `Failed to save brand profile: ${sanitizeDbError(error)}`
4730
+ text: formatToolError(`Failed to save brand profile: ${sanitizeDbError(error)}`)
4567
4731
  }
4568
4732
  ],
4569
4733
  isError: true
@@ -4612,15 +4776,32 @@ Version: ${payload.version ?? "N/A"}`
4612
4776
  "facebook",
4613
4777
  "threads",
4614
4778
  "bluesky"
4615
- ]),
4779
+ ]).describe("Social platform to set voice overrides for."),
4616
4780
  project_id: z5.string().uuid().optional().describe("Project ID. Defaults to active project context."),
4617
4781
  samples: z5.string().max(3e3).optional().describe("3-5 real platform post examples for style anchoring."),
4618
- tone: z5.array(z5.string()).optional(),
4619
- style: z5.array(z5.string()).optional(),
4620
- avoid_patterns: z5.array(z5.string()).optional(),
4621
- hashtag_strategy: z5.string().max(300).optional(),
4622
- cta_style: z5.string().max(300).optional(),
4623
- response_format: z5.enum(["text", "json"]).optional()
4782
+ tone: z5.array(z5.string()).optional().describe(
4783
+ 'Tone descriptors for this platform (e.g. ["casual", "witty", "informative"]).'
4784
+ ),
4785
+ style: z5.array(z5.string()).optional().describe(
4786
+ 'Writing style tags (e.g. ["short-form", "emoji-heavy", "storytelling"]).'
4787
+ ),
4788
+ avoid_patterns: z5.array(z5.string()).optional().describe(
4789
+ 'Phrases or patterns the brand should never use on this platform (e.g. ["click here", "buy now"]).'
4790
+ ),
4791
+ hashtag_strategy: z5.string().max(300).optional().describe(
4792
+ 'Hashtag usage guidelines for this platform (e.g. "3-5 niche hashtags, no generic tags").'
4793
+ ),
4794
+ cta_style: z5.string().max(300).optional().describe(
4795
+ 'Preferred call-to-action style (e.g. "soft CTA with question" or "direct link in bio").'
4796
+ ),
4797
+ response_format: z5.enum(["text", "json"]).optional().describe("Response format. Defaults to text.")
4798
+ },
4799
+ {
4800
+ title: "Update Platform Voice",
4801
+ readOnlyHint: false,
4802
+ destructiveHint: false,
4803
+ idempotentHint: false,
4804
+ openWorldHint: false
4624
4805
  },
4625
4806
  async ({
4626
4807
  platform: platform2,
@@ -4722,7 +4903,7 @@ Version: ${payload.version ?? "N/A"}`
4722
4903
  content: [
4723
4904
  {
4724
4905
  type: "text",
4725
- text: `Failed to update platform voice: ${saveError.message}`
4906
+ text: formatToolError(`Failed to update platform voice: ${saveError.message}`)
4726
4907
  }
4727
4908
  ],
4728
4909
  isError: true
@@ -4869,6 +5050,13 @@ function registerScreenshotTools(server) {
4869
5050
  "Extra milliseconds to wait after page load before capturing. Useful for animations. Defaults to 2000."
4870
5051
  )
4871
5052
  },
5053
+ {
5054
+ title: "Capture App Page",
5055
+ readOnlyHint: true,
5056
+ destructiveHint: false,
5057
+ idempotentHint: false,
5058
+ openWorldHint: false
5059
+ },
4872
5060
  async ({ page: pageName, viewport, theme, selector, wait_ms }) => {
4873
5061
  const startedAt = Date.now();
4874
5062
  let rateLimitKey = "anonymous";
@@ -4989,6 +5177,13 @@ function registerScreenshotTools(server) {
4989
5177
  ),
4990
5178
  wait_ms: z6.number().min(0).max(3e4).optional().describe("Extra milliseconds to wait after page load before capturing. Defaults to 1000.")
4991
5179
  },
5180
+ {
5181
+ title: "Capture Screenshot",
5182
+ readOnlyHint: true,
5183
+ destructiveHint: false,
5184
+ idempotentHint: false,
5185
+ openWorldHint: true
5186
+ },
4992
5187
  async ({ url, viewport, selector, output_path, wait_ms }) => {
4993
5188
  const startedAt = Date.now();
4994
5189
  let rateLimitKey = "anonymous";
@@ -5251,6 +5446,13 @@ function registerRemotionTools(server) {
5251
5446
  "list_compositions",
5252
5447
  "List all available Remotion video compositions defined in Social Neuron. Returns composition IDs, dimensions, duration, and descriptions. Use this to discover what videos can be rendered with render_demo_video.",
5253
5448
  {},
5449
+ {
5450
+ title: "List Compositions",
5451
+ readOnlyHint: true,
5452
+ destructiveHint: false,
5453
+ idempotentHint: true,
5454
+ openWorldHint: false
5455
+ },
5254
5456
  async () => {
5255
5457
  const lines = [`${COMPOSITIONS.length} Remotion compositions available:`, ""];
5256
5458
  for (const comp of COMPOSITIONS) {
@@ -5279,6 +5481,13 @@ function registerRemotionTools(server) {
5279
5481
  "JSON string of input props to pass to the composition. Each composition accepts different props. Omit for defaults."
5280
5482
  )
5281
5483
  },
5484
+ {
5485
+ title: "Render Demo Video",
5486
+ readOnlyHint: false,
5487
+ destructiveHint: false,
5488
+ idempotentHint: false,
5489
+ openWorldHint: false
5490
+ },
5282
5491
  async ({ composition_id, output_format, props }) => {
5283
5492
  const startedAt = Date.now();
5284
5493
  const userId = await getDefaultUserId();
@@ -5459,6 +5668,13 @@ function registerInsightsTools(server) {
5459
5668
  limit: z8.number().min(1).max(50).optional().describe("Maximum number of insights to return. Defaults to 10."),
5460
5669
  response_format: z8.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
5461
5670
  },
5671
+ {
5672
+ title: "Get Performance Insights",
5673
+ readOnlyHint: true,
5674
+ destructiveHint: false,
5675
+ idempotentHint: true,
5676
+ openWorldHint: false
5677
+ },
5462
5678
  async ({ insight_type, days, limit, response_format }) => {
5463
5679
  const format = response_format ?? "text";
5464
5680
  const supabase = getSupabaseClient();
@@ -5586,6 +5802,13 @@ function registerInsightsTools(server) {
5586
5802
  days: z8.number().min(1).max(90).optional().describe("Number of days to analyze. Defaults to 30. Max 90."),
5587
5803
  response_format: z8.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
5588
5804
  },
5805
+ {
5806
+ title: "Get Best Posting Times",
5807
+ readOnlyHint: true,
5808
+ destructiveHint: false,
5809
+ idempotentHint: true,
5810
+ openWorldHint: false
5811
+ },
5589
5812
  async ({ platform: platform2, days, response_format }) => {
5590
5813
  const format = response_format ?? "text";
5591
5814
  const supabase = getSupabaseClient();
@@ -5734,6 +5957,13 @@ function registerYouTubeAnalyticsTools(server) {
5734
5957
  video_id: z9.string().optional().describe('YouTube video ID. Required when action is "video".'),
5735
5958
  max_results: z9.number().min(1).max(50).optional().describe('Max videos to return for "topVideos" action. Defaults to 10.')
5736
5959
  },
5960
+ {
5961
+ title: "Fetch YouTube Analytics",
5962
+ readOnlyHint: true,
5963
+ destructiveHint: false,
5964
+ idempotentHint: true,
5965
+ openWorldHint: true
5966
+ },
5737
5967
  async ({ action, start_date, end_date, video_id, max_results }) => {
5738
5968
  if (action === "video" && !video_id) {
5739
5969
  return {
@@ -5852,6 +6082,13 @@ function registerCommentsTools(server) {
5852
6082
  page_token: z10.string().optional().describe("Pagination cursor from previous list_comments response nextPageToken field. Omit for first page of results."),
5853
6083
  response_format: z10.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
5854
6084
  },
6085
+ {
6086
+ title: "List Comments",
6087
+ readOnlyHint: true,
6088
+ destructiveHint: false,
6089
+ idempotentHint: true,
6090
+ openWorldHint: true
6091
+ },
5855
6092
  async ({ video_id, max_results, page_token, response_format }) => {
5856
6093
  const format = response_format ?? "text";
5857
6094
  const { data, error } = await callEdgeFunction("youtube-comments", {
@@ -5863,7 +6100,7 @@ function registerCommentsTools(server) {
5863
6100
  if (error) {
5864
6101
  return {
5865
6102
  content: [
5866
- { type: "text", text: `Error listing comments: ${error}` }
6103
+ { type: "text", text: formatToolError(`Error listing comments: ${error}`) }
5867
6104
  ],
5868
6105
  isError: true
5869
6106
  };
@@ -5922,6 +6159,13 @@ function registerCommentsTools(server) {
5922
6159
  text: z10.string().min(1).describe("The reply text."),
5923
6160
  response_format: z10.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
5924
6161
  },
6162
+ {
6163
+ title: "Reply to Comment",
6164
+ readOnlyHint: false,
6165
+ destructiveHint: false,
6166
+ idempotentHint: false,
6167
+ openWorldHint: true
6168
+ },
5925
6169
  async ({ parent_id, text, response_format }) => {
5926
6170
  const format = response_format ?? "text";
5927
6171
  const startedAt = Date.now();
@@ -5960,7 +6204,7 @@ function registerCommentsTools(server) {
5960
6204
  content: [
5961
6205
  {
5962
6206
  type: "text",
5963
- text: `Error replying to comment: ${error}`
6207
+ text: formatToolError(`Error replying to comment: ${error}`)
5964
6208
  }
5965
6209
  ],
5966
6210
  isError: true
@@ -6003,6 +6247,13 @@ function registerCommentsTools(server) {
6003
6247
  text: z10.string().min(1).describe("The comment text."),
6004
6248
  response_format: z10.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
6005
6249
  },
6250
+ {
6251
+ title: "Post Comment",
6252
+ readOnlyHint: false,
6253
+ destructiveHint: false,
6254
+ idempotentHint: false,
6255
+ openWorldHint: true
6256
+ },
6006
6257
  async ({ video_id, text, response_format }) => {
6007
6258
  const format = response_format ?? "text";
6008
6259
  const startedAt = Date.now();
@@ -6039,7 +6290,7 @@ function registerCommentsTools(server) {
6039
6290
  });
6040
6291
  return {
6041
6292
  content: [
6042
- { type: "text", text: `Error posting comment: ${error}` }
6293
+ { type: "text", text: formatToolError(`Error posting comment: ${error}`) }
6043
6294
  ],
6044
6295
  isError: true
6045
6296
  };
@@ -6081,6 +6332,13 @@ function registerCommentsTools(server) {
6081
6332
  moderation_status: z10.enum(["published", "rejected"]).describe('"published" to approve, "rejected" to hide.'),
6082
6333
  response_format: z10.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
6083
6334
  },
6335
+ {
6336
+ title: "Moderate Comment",
6337
+ readOnlyHint: false,
6338
+ destructiveHint: false,
6339
+ idempotentHint: true,
6340
+ openWorldHint: true
6341
+ },
6084
6342
  async ({ comment_id, moderation_status, response_format }) => {
6085
6343
  const format = response_format ?? "text";
6086
6344
  const startedAt = Date.now();
@@ -6119,7 +6377,7 @@ function registerCommentsTools(server) {
6119
6377
  content: [
6120
6378
  {
6121
6379
  type: "text",
6122
- text: `Error moderating comment: ${error}`
6380
+ text: formatToolError(`Error moderating comment: ${error}`)
6123
6381
  }
6124
6382
  ],
6125
6383
  isError: true
@@ -6166,6 +6424,13 @@ function registerCommentsTools(server) {
6166
6424
  comment_id: z10.string().describe("The comment ID to delete."),
6167
6425
  response_format: z10.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
6168
6426
  },
6427
+ {
6428
+ title: "Delete Comment",
6429
+ readOnlyHint: false,
6430
+ destructiveHint: true,
6431
+ idempotentHint: true,
6432
+ openWorldHint: true
6433
+ },
6169
6434
  async ({ comment_id, response_format }) => {
6170
6435
  const format = response_format ?? "text";
6171
6436
  const startedAt = Date.now();
@@ -6201,7 +6466,7 @@ function registerCommentsTools(server) {
6201
6466
  });
6202
6467
  return {
6203
6468
  content: [
6204
- { type: "text", text: `Error deleting comment: ${error}` }
6469
+ { type: "text", text: formatToolError(`Error deleting comment: ${error}`) }
6205
6470
  ],
6206
6471
  isError: true
6207
6472
  };
@@ -6314,12 +6579,19 @@ function asEnvelope7(data) {
6314
6579
  function registerIdeationContextTools(server) {
6315
6580
  server.tool(
6316
6581
  "get_ideation_context",
6317
- "Get synthesized ideation context from performance insights. Returns the same prompt-injection context used by ideation generation.",
6582
+ "Load performance-derived context (top hooks, optimal timing, winning patterns) that should inform your next content generation. Call this before generate_content or plan_content_week to ground new content in what has actually performed well. Returns a promptInjection string ready to pass into generation tools.",
6318
6583
  {
6319
6584
  project_id: z11.string().uuid().optional().describe("Project ID to scope insights."),
6320
6585
  days: z11.number().min(1).max(90).optional().describe("Lookback window for insights. Defaults to 30 days."),
6321
6586
  response_format: z11.enum(["text", "json"]).optional().describe("Optional output format. Defaults to text.")
6322
6587
  },
6588
+ {
6589
+ title: "Get Ideation Context",
6590
+ readOnlyHint: true,
6591
+ destructiveHint: false,
6592
+ idempotentHint: true,
6593
+ openWorldHint: false
6594
+ },
6323
6595
  async ({ project_id, days, response_format }) => {
6324
6596
  const supabase = getSupabaseClient();
6325
6597
  const userId = await getDefaultUserId();
@@ -6449,6 +6721,13 @@ function registerCreditsTools(server) {
6449
6721
  {
6450
6722
  response_format: z12.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
6451
6723
  },
6724
+ {
6725
+ title: "Get Credit Balance",
6726
+ readOnlyHint: true,
6727
+ destructiveHint: false,
6728
+ idempotentHint: true,
6729
+ openWorldHint: false
6730
+ },
6452
6731
  async ({ response_format }) => {
6453
6732
  const supabase = getSupabaseClient();
6454
6733
  const userId = await getDefaultUserId();
@@ -6461,7 +6740,7 @@ function registerCreditsTools(server) {
6461
6740
  content: [
6462
6741
  {
6463
6742
  type: "text",
6464
- text: `Failed to fetch credit balance: ${sanitizeDbError(profileResult.error)}`
6743
+ text: formatToolError(`Failed to fetch credit balance: ${sanitizeDbError(profileResult.error)}`)
6465
6744
  }
6466
6745
  ],
6467
6746
  isError: true
@@ -6502,6 +6781,13 @@ Monthly used: ${payload.monthlyUsed}` + (payload.monthlyLimit ? ` / ${payload.mo
6502
6781
  {
6503
6782
  response_format: z12.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
6504
6783
  },
6784
+ {
6785
+ title: "Get Budget Status",
6786
+ readOnlyHint: true,
6787
+ destructiveHint: false,
6788
+ idempotentHint: true,
6789
+ openWorldHint: false
6790
+ },
6505
6791
  async ({ response_format }) => {
6506
6792
  const budget = getCurrentBudgetStatus();
6507
6793
  const payload = {
@@ -6555,11 +6841,18 @@ function asEnvelope9(data) {
6555
6841
  function registerLoopSummaryTools(server) {
6556
6842
  server.tool(
6557
6843
  "get_loop_summary",
6558
- "Get a one-call dashboard summary of the feedback loop state (brand profile, recent content, and current insights).",
6844
+ "Get a single-call health check of the content feedback loop: brand profile status, recent content, and active insights. Call at the start of a session to decide what to do next. The response includes a recommendedNextAction field that tells you which tool to call.",
6559
6845
  {
6560
6846
  project_id: z13.string().uuid().optional().describe("Project ID. Defaults to active project context."),
6561
6847
  response_format: z13.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
6562
6848
  },
6849
+ {
6850
+ title: "Get Loop Summary",
6851
+ readOnlyHint: true,
6852
+ destructiveHint: false,
6853
+ idempotentHint: true,
6854
+ openWorldHint: false
6855
+ },
6563
6856
  async ({ project_id, response_format }) => {
6564
6857
  const supabase = getSupabaseClient();
6565
6858
  const userId = await getDefaultUserId();
@@ -6657,6 +6950,13 @@ function registerUsageTools(server) {
6657
6950
  {
6658
6951
  response_format: z14.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
6659
6952
  },
6953
+ {
6954
+ title: "Get MCP Usage",
6955
+ readOnlyHint: true,
6956
+ destructiveHint: false,
6957
+ idempotentHint: true,
6958
+ openWorldHint: false
6959
+ },
6660
6960
  async ({ response_format }) => {
6661
6961
  const format = response_format ?? "text";
6662
6962
  const supabase = getSupabaseClient();
@@ -6746,6 +7046,13 @@ function registerAutopilotTools(server) {
6746
7046
  active_only: z15.boolean().optional().describe("If true, only return active configs. Defaults to false (show all)."),
6747
7047
  response_format: z15.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
6748
7048
  },
7049
+ {
7050
+ title: "List Autopilot Configs",
7051
+ readOnlyHint: true,
7052
+ destructiveHint: false,
7053
+ idempotentHint: true,
7054
+ openWorldHint: false
7055
+ },
6749
7056
  async ({ active_only, response_format }) => {
6750
7057
  const format = response_format ?? "text";
6751
7058
  const supabase = getSupabaseClient();
@@ -6823,11 +7130,18 @@ ${"=".repeat(40)}
6823
7130
  {
6824
7131
  config_id: z15.string().uuid().describe("The autopilot config ID to update."),
6825
7132
  is_active: z15.boolean().optional().describe("Enable or disable this autopilot config."),
6826
- schedule_days: z15.array(z15.enum(["mon", "tue", "wed", "thu", "fri", "sat", "sun"])).optional().describe('Days of the week to run (e.g., ["mon", "wed", "fri"]).'),
7133
+ schedule_days: z15.array(z15.enum(["mon", "tue", "wed", "thu", "fri", "sat", "sun"]).describe("Three-letter lowercase day abbreviation.")).optional().describe('Days of the week to run (e.g. ["mon", "wed", "fri"]).'),
6827
7134
  schedule_time: z15.string().optional().describe('Time to run in HH:MM format (24h, user timezone). E.g., "09:00".'),
6828
7135
  max_credits_per_run: z15.number().optional().describe("Maximum credits per execution."),
6829
7136
  max_credits_per_week: z15.number().optional().describe("Maximum credits per week.")
6830
7137
  },
7138
+ {
7139
+ title: "Update Autopilot Config",
7140
+ readOnlyHint: false,
7141
+ destructiveHint: false,
7142
+ idempotentHint: true,
7143
+ openWorldHint: false
7144
+ },
6831
7145
  async ({
6832
7146
  config_id,
6833
7147
  is_active,
@@ -6891,6 +7205,13 @@ Schedule: ${JSON.stringify(updated.schedule_config)}`
6891
7205
  {
6892
7206
  response_format: z15.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
6893
7207
  },
7208
+ {
7209
+ title: "Get Autopilot Status",
7210
+ readOnlyHint: true,
7211
+ destructiveHint: false,
7212
+ idempotentHint: true,
7213
+ openWorldHint: false
7214
+ },
6894
7215
  async ({ response_format }) => {
6895
7216
  const format = response_format ?? "text";
6896
7217
  const supabase = getSupabaseClient();
@@ -7010,7 +7331,14 @@ function registerExtractionTools(server) {
7010
7331
  extract_type: z16.enum(["auto", "transcript", "article", "product"]).default("auto").describe("Type of extraction"),
7011
7332
  include_comments: z16.boolean().default(false).describe("Include top comments (YouTube only)"),
7012
7333
  max_results: z16.number().min(1).max(100).default(10).describe("Max comments to include"),
7013
- response_format: z16.enum(["text", "json"]).default("text")
7334
+ response_format: z16.enum(["text", "json"]).default("text").describe("Response format. Defaults to text.")
7335
+ },
7336
+ {
7337
+ title: "Extract URL Content",
7338
+ readOnlyHint: true,
7339
+ destructiveHint: false,
7340
+ idempotentHint: true,
7341
+ openWorldHint: true
7014
7342
  },
7015
7343
  async ({
7016
7344
  url,
@@ -7053,7 +7381,7 @@ function registerExtractionTools(server) {
7053
7381
  content: [
7054
7382
  {
7055
7383
  type: "text",
7056
- text: `Failed to extract YouTube video: ${error ?? "No data returned"}`
7384
+ text: formatToolError(`Failed to extract YouTube video: ${error ?? "No data returned"}`)
7057
7385
  }
7058
7386
  ],
7059
7387
  isError: true
@@ -7093,7 +7421,7 @@ function registerExtractionTools(server) {
7093
7421
  content: [
7094
7422
  {
7095
7423
  type: "text",
7096
- text: `Failed to extract YouTube channel: ${error ?? "No data returned"}`
7424
+ text: formatToolError(`Failed to extract YouTube channel: ${error ?? "No data returned"}`)
7097
7425
  }
7098
7426
  ],
7099
7427
  isError: true
@@ -7124,7 +7452,7 @@ function registerExtractionTools(server) {
7124
7452
  content: [
7125
7453
  {
7126
7454
  type: "text",
7127
- text: `Failed to extract URL content: ${error ?? "No data returned"}`
7455
+ text: formatToolError(`Failed to extract URL content: ${error ?? "No data returned"}`)
7128
7456
  }
7129
7457
  ],
7130
7458
  isError: true
@@ -7181,7 +7509,7 @@ function registerExtractionTools(server) {
7181
7509
  });
7182
7510
  return {
7183
7511
  content: [
7184
- { type: "text", text: `Extraction failed: ${message}` }
7512
+ { type: "text", text: formatToolError(`Extraction failed: ${message}`) }
7185
7513
  ],
7186
7514
  isError: true
7187
7515
  };
@@ -7220,9 +7548,16 @@ function registerQualityTools(server) {
7220
7548
  ).min(1).describe("Target platforms"),
7221
7549
  threshold: z17.number().min(0).max(35).default(26).describe("Minimum total score to pass (max 35, scored across 7 categories at 0-5 each). Default 26 (~75%). Use 20 for rough drafts, 28+ for final posts going to large audiences."),
7222
7550
  brand_keyword: z17.string().optional().describe("Brand keyword for alignment check"),
7223
- brand_avoid_patterns: z17.array(z17.string()).optional(),
7224
- custom_banned_terms: z17.array(z17.string()).optional(),
7225
- response_format: z17.enum(["text", "json"]).default("text")
7551
+ brand_avoid_patterns: z17.array(z17.string()).optional().describe("Phrases the brand should never use (e.g. competitor names, off-brand slang). Matched case-insensitively."),
7552
+ custom_banned_terms: z17.array(z17.string()).optional().describe("Additional banned words beyond the built-in safety list. Useful for industry-specific compliance terms."),
7553
+ response_format: z17.enum(["text", "json"]).default("text").describe("'text' for human-readable report, 'json' for structured scores suitable for pipeline automation.")
7554
+ },
7555
+ {
7556
+ title: "Quality Check",
7557
+ readOnlyHint: true,
7558
+ destructiveHint: false,
7559
+ idempotentHint: true,
7560
+ openWorldHint: false
7226
7561
  },
7227
7562
  async ({
7228
7563
  caption,
@@ -7294,15 +7629,22 @@ function registerQualityTools(server) {
7294
7629
  plan: z17.object({
7295
7630
  posts: z17.array(
7296
7631
  z17.object({
7297
- id: z17.string(),
7298
- caption: z17.string(),
7299
- title: z17.string().optional(),
7300
- platform: z17.string()
7632
+ id: z17.string().describe("Unique post identifier."),
7633
+ caption: z17.string().describe("Post caption/body text to quality-check."),
7634
+ title: z17.string().optional().describe("Post title (important for YouTube)."),
7635
+ platform: z17.string().describe("Target platform (e.g. instagram, youtube).")
7301
7636
  })
7302
7637
  )
7303
- }).passthrough().describe("Content plan with posts array"),
7638
+ }).passthrough().describe("Content plan with posts array."),
7304
7639
  threshold: z17.number().min(0).max(35).default(26).describe("Minimum total score to pass (max 35, scored across 7 categories at 0-5 each). Default 26 (~75%). Use 20 for rough drafts, 28+ for final posts going to large audiences."),
7305
- response_format: z17.enum(["text", "json"]).default("text")
7640
+ response_format: z17.enum(["text", "json"]).default("text").describe("Response format. Defaults to text.")
7641
+ },
7642
+ {
7643
+ title: "Quality Check Plan",
7644
+ readOnlyHint: true,
7645
+ destructiveHint: false,
7646
+ idempotentHint: true,
7647
+ openWorldHint: false
7306
7648
  },
7307
7649
  async ({ plan, threshold, response_format }) => {
7308
7650
  const startedAt = Date.now();
@@ -7503,7 +7845,14 @@ function registerPlanningTools(server) {
7503
7845
  start_date: z18.string().optional().describe("ISO date, defaults to tomorrow"),
7504
7846
  brand_voice: z18.string().optional().describe("Override brand voice description"),
7505
7847
  project_id: z18.string().optional().describe("Project ID for brand/insights context"),
7506
- response_format: z18.enum(["text", "json"]).default("json")
7848
+ response_format: z18.enum(["text", "json"]).default("json").describe("Response format. Defaults to json.")
7849
+ },
7850
+ {
7851
+ title: "Plan Content Week",
7852
+ readOnlyHint: false,
7853
+ destructiveHint: false,
7854
+ idempotentHint: false,
7855
+ openWorldHint: true
7507
7856
  },
7508
7857
  async ({
7509
7858
  topic,
@@ -7658,7 +8007,7 @@ ${ideationContext.promptInjection.slice(0, 1500)}` : "",
7658
8007
  content: [
7659
8008
  {
7660
8009
  type: "text",
7661
- text: `Plan generation failed: ${aiError ?? "No response from AI"}`
8010
+ text: formatToolError(`Plan generation failed: ${aiError ?? "No response from AI"}`)
7662
8011
  }
7663
8012
  ],
7664
8013
  isError: true
@@ -7678,10 +8027,10 @@ ${ideationContext.promptInjection.slice(0, 1500)}` : "",
7678
8027
  content: [
7679
8028
  {
7680
8029
  type: "text",
7681
- text: `AI response could not be parsed as JSON.
8030
+ text: formatToolError(`AI response could not be parsed as JSON.
7682
8031
 
7683
8032
  Raw output (first 1000 chars):
7684
- ${rawText.slice(0, 1e3)}`
8033
+ ${rawText.slice(0, 1e3)}`)
7685
8034
  }
7686
8035
  ],
7687
8036
  isError: true
@@ -7808,7 +8157,7 @@ ${rawText.slice(0, 1e3)}`
7808
8157
  content: [
7809
8158
  {
7810
8159
  type: "text",
7811
- text: `Plan generation failed: ${message}`
8160
+ text: formatToolError(`Plan generation failed: ${message}`)
7812
8161
  }
7813
8162
  ],
7814
8163
  isError: true
@@ -7821,12 +8170,19 @@ ${rawText.slice(0, 1e3)}`
7821
8170
  "Save a content plan to the database for team review, approval workflows, and scheduled publishing. Creates a plan_id you can reference in get_content_plan, update_content_plan, and schedule_content_plan.",
7822
8171
  {
7823
8172
  plan: z18.object({
7824
- topic: z18.string(),
7825
- posts: z18.array(z18.record(z18.string(), z18.unknown()))
7826
- }).passthrough(),
7827
- project_id: z18.string().uuid().optional(),
7828
- status: z18.enum(["draft", "in_review", "approved", "scheduled", "completed"]).default("draft"),
7829
- response_format: z18.enum(["text", "json"]).default("json")
8173
+ topic: z18.string().describe("Content plan topic or theme."),
8174
+ posts: z18.array(z18.record(z18.string(), z18.unknown())).describe("Array of post objects to save.")
8175
+ }).passthrough().describe("Content plan object with topic and posts array."),
8176
+ project_id: z18.string().uuid().optional().describe("Project ID. Defaults to active project context."),
8177
+ status: z18.enum(["draft", "in_review", "approved", "scheduled", "completed"]).default("draft").describe("Initial plan status. Defaults to draft."),
8178
+ response_format: z18.enum(["text", "json"]).default("json").describe("Response format. Defaults to json.")
8179
+ },
8180
+ {
8181
+ title: "Save Content Plan",
8182
+ readOnlyHint: false,
8183
+ destructiveHint: false,
8184
+ idempotentHint: false,
8185
+ openWorldHint: false
7830
8186
  },
7831
8187
  async ({ plan, project_id, status, response_format }) => {
7832
8188
  const startedAt = Date.now();
@@ -7914,7 +8270,7 @@ ${rawText.slice(0, 1e3)}`
7914
8270
  content: [
7915
8271
  {
7916
8272
  type: "text",
7917
- text: `Failed to save content plan: ${message}`
8273
+ text: formatToolError(`Failed to save content plan: ${message}`)
7918
8274
  }
7919
8275
  ],
7920
8276
  isError: true
@@ -7924,10 +8280,17 @@ ${rawText.slice(0, 1e3)}`
7924
8280
  );
7925
8281
  server.tool(
7926
8282
  "get_content_plan",
7927
- "Retrieve a persisted content plan by ID.",
8283
+ "Retrieve a saved content plan to review its posts, status, and applied insights. Use after plan_content_week or save_content_plan to inspect what was generated. Feed the result into update_content_plan to revise posts or submit_content_plan_for_approval to start the review workflow.",
7928
8284
  {
7929
8285
  plan_id: z18.string().uuid().describe("Persisted content plan ID"),
7930
- response_format: z18.enum(["text", "json"]).default("json")
8286
+ response_format: z18.enum(["text", "json"]).default("json").describe("Response format. Defaults to json.")
8287
+ },
8288
+ {
8289
+ title: "Get Content Plan",
8290
+ readOnlyHint: true,
8291
+ destructiveHint: false,
8292
+ idempotentHint: true,
8293
+ openWorldHint: false
7931
8294
  },
7932
8295
  async ({ plan_id, response_format }) => {
7933
8296
  const supabase = getSupabaseClient();
@@ -7940,7 +8303,7 @@ ${rawText.slice(0, 1e3)}`
7940
8303
  content: [
7941
8304
  {
7942
8305
  type: "text",
7943
- text: `Failed to load content plan: ${sanitizeDbError(error)}`
8306
+ text: formatToolError(`Failed to load content plan: ${sanitizeDbError(error)}`)
7944
8307
  }
7945
8308
  ],
7946
8309
  isError: true
@@ -7951,7 +8314,7 @@ ${rawText.slice(0, 1e3)}`
7951
8314
  content: [
7952
8315
  {
7953
8316
  type: "text",
7954
- text: `No content plan found for plan_id=${plan_id}`
8317
+ text: formatToolError(`No content plan found for plan_id=${plan_id}`)
7955
8318
  }
7956
8319
  ],
7957
8320
  isError: true
@@ -7992,25 +8355,32 @@ ${rawText.slice(0, 1e3)}`
7992
8355
  );
7993
8356
  server.tool(
7994
8357
  "update_content_plan",
7995
- "Update individual posts in a persisted content plan.",
8358
+ "Revise specific posts in a saved content plan -- edit captions, hooks, hashtags, schedule times, or mark posts as approved/rejected. Call after reviewing a plan with get_content_plan. When all posts are approved, the plan status auto-advances so it can be scheduled.",
7996
8359
  {
7997
- plan_id: z18.string().uuid(),
8360
+ plan_id: z18.string().uuid().describe("Content plan ID to update."),
7998
8361
  post_updates: z18.array(
7999
8362
  z18.object({
8000
- post_id: z18.string(),
8001
- caption: z18.string().optional(),
8002
- title: z18.string().optional(),
8003
- hashtags: z18.array(z18.string()).optional(),
8004
- hook: z18.string().optional(),
8005
- angle: z18.string().optional(),
8006
- visual_direction: z18.string().optional(),
8007
- media_url: z18.string().optional(),
8008
- schedule_at: z18.string().optional(),
8009
- platform: z18.string().optional(),
8010
- status: z18.enum(["approved", "rejected", "needs_edit"]).optional()
8363
+ post_id: z18.string().describe("ID of the post to update within this plan."),
8364
+ caption: z18.string().optional().describe("Revised caption/body text."),
8365
+ title: z18.string().optional().describe("Revised post title."),
8366
+ hashtags: z18.array(z18.string()).optional().describe("Revised hashtags array."),
8367
+ hook: z18.string().optional().describe("Revised attention-grabbing opening line."),
8368
+ angle: z18.string().optional().describe("Revised content angle or perspective."),
8369
+ visual_direction: z18.string().optional().describe("Revised visual/media direction notes."),
8370
+ media_url: z18.string().optional().describe("Revised media URL (public or R2 signed URL)."),
8371
+ schedule_at: z18.string().optional().describe("Revised ISO 8601 UTC publish datetime."),
8372
+ platform: z18.string().optional().describe("Revised target platform."),
8373
+ status: z18.enum(["approved", "rejected", "needs_edit"]).optional().describe("Review status for this post.")
8011
8374
  })
8012
- ).min(1),
8013
- response_format: z18.enum(["text", "json"]).default("json")
8375
+ ).min(1).describe("Array of post-level updates to apply."),
8376
+ response_format: z18.enum(["text", "json"]).default("json").describe("Response format. Defaults to json.")
8377
+ },
8378
+ {
8379
+ title: "Update Content Plan",
8380
+ readOnlyHint: false,
8381
+ destructiveHint: false,
8382
+ idempotentHint: true,
8383
+ openWorldHint: false
8014
8384
  },
8015
8385
  async ({ plan_id, post_updates, response_format }) => {
8016
8386
  const supabase = getSupabaseClient();
@@ -8032,7 +8402,7 @@ ${rawText.slice(0, 1e3)}`
8032
8402
  content: [
8033
8403
  {
8034
8404
  type: "text",
8035
- text: `No content plan found for plan_id=${plan_id}`
8405
+ text: formatToolError(`No content plan found for plan_id=${plan_id}`)
8036
8406
  }
8037
8407
  ],
8038
8408
  isError: true
@@ -8076,7 +8446,7 @@ ${rawText.slice(0, 1e3)}`
8076
8446
  content: [
8077
8447
  {
8078
8448
  type: "text",
8079
- text: `Failed to update content plan: ${sanitizeDbError(saveError)}`
8449
+ text: formatToolError(`Failed to update content plan: ${sanitizeDbError(saveError)}`)
8080
8450
  }
8081
8451
  ],
8082
8452
  isError: true
@@ -8111,10 +8481,17 @@ ${rawText.slice(0, 1e3)}`
8111
8481
  );
8112
8482
  server.tool(
8113
8483
  "submit_content_plan_for_approval",
8114
- "Create pending approval items for each post in a plan and mark plan status as in_review.",
8484
+ "Submit an entire saved content plan for team review in one call -- creates approval items for every post and sets the plan to in_review status. Call after plan_content_week and any update_content_plan edits are done. Use list_plan_approvals to track reviewer decisions.",
8115
8485
  {
8116
- plan_id: z18.string().uuid(),
8117
- response_format: z18.enum(["text", "json"]).default("json")
8486
+ plan_id: z18.string().uuid().describe("Content plan ID to submit for review."),
8487
+ response_format: z18.enum(["text", "json"]).default("json").describe("Response format. Defaults to json.")
8488
+ },
8489
+ {
8490
+ title: "Submit Plan for Approval",
8491
+ readOnlyHint: false,
8492
+ destructiveHint: false,
8493
+ idempotentHint: true,
8494
+ openWorldHint: false
8118
8495
  },
8119
8496
  async ({ plan_id, response_format }) => {
8120
8497
  const supabase = getSupabaseClient();
@@ -8136,7 +8513,7 @@ ${rawText.slice(0, 1e3)}`
8136
8513
  content: [
8137
8514
  {
8138
8515
  type: "text",
8139
- text: `No content plan found for plan_id=${plan_id}`
8516
+ text: formatToolError(`No content plan found for plan_id=${plan_id}`)
8140
8517
  }
8141
8518
  ],
8142
8519
  isError: true
@@ -8149,7 +8526,7 @@ ${rawText.slice(0, 1e3)}`
8149
8526
  content: [
8150
8527
  {
8151
8528
  type: "text",
8152
- text: `Plan ${plan_id} has no posts to submit.`
8529
+ text: formatToolError(`Plan ${plan_id} has no posts to submit.`)
8153
8530
  }
8154
8531
  ],
8155
8532
  isError: true
@@ -8169,7 +8546,7 @@ ${rawText.slice(0, 1e3)}`
8169
8546
  content: [
8170
8547
  {
8171
8548
  type: "text",
8172
- text: `Failed to create approvals: ${sanitizeDbError(approvalError)}`
8549
+ text: formatToolError(`Failed to create approvals: ${sanitizeDbError(approvalError)}`)
8173
8550
  }
8174
8551
  ],
8175
8552
  isError: true
@@ -8181,7 +8558,7 @@ ${rawText.slice(0, 1e3)}`
8181
8558
  content: [
8182
8559
  {
8183
8560
  type: "text",
8184
- text: `Failed to update plan status: ${sanitizeDbError(statusError)}`
8561
+ text: formatToolError(`Failed to update plan status: ${sanitizeDbError(statusError)}`)
8185
8562
  }
8186
8563
  ],
8187
8564
  isError: true
@@ -8238,21 +8615,28 @@ async function assertProjectAccess(supabase, userId, projectId) {
8238
8615
  function registerPlanApprovalTools(server) {
8239
8616
  server.tool(
8240
8617
  "create_plan_approvals",
8241
- "Create pending approval rows for each post in a content plan.",
8618
+ "Create individual approval items for posts you supply explicitly, useful when building a custom approval queue outside the standard plan workflow. Requires the post array as input. Use list_plan_approvals to check status afterward, and respond_plan_approval to approve or reject each item.",
8242
8619
  {
8243
8620
  plan_id: z19.string().uuid().describe("Content plan ID"),
8244
8621
  posts: z19.array(
8245
8622
  z19.object({
8246
- id: z19.string(),
8247
- platform: z19.string().optional(),
8248
- caption: z19.string().optional(),
8249
- title: z19.string().optional(),
8250
- media_url: z19.string().optional(),
8251
- schedule_at: z19.string().optional()
8623
+ id: z19.string().describe("Unique post identifier from the content plan."),
8624
+ platform: z19.string().optional().describe("Target platform (e.g. instagram, youtube)."),
8625
+ caption: z19.string().optional().describe("Post caption/body text."),
8626
+ title: z19.string().optional().describe("Post title, used by YouTube and LinkedIn articles."),
8627
+ media_url: z19.string().optional().describe("Public or R2 signed URL for the post media."),
8628
+ schedule_at: z19.string().optional().describe("ISO 8601 UTC datetime to publish (e.g. 2026-03-20T14:00:00Z).")
8252
8629
  }).passthrough()
8253
8630
  ).min(1).describe("Posts to create approval entries for."),
8254
8631
  project_id: z19.string().uuid().optional().describe("Project ID. Defaults to active project context."),
8255
- response_format: z19.enum(["text", "json"]).optional()
8632
+ response_format: z19.enum(["text", "json"]).optional().describe("Response format. Defaults to text.")
8633
+ },
8634
+ {
8635
+ title: "Create Plan Approvals",
8636
+ readOnlyHint: false,
8637
+ destructiveHint: false,
8638
+ idempotentHint: false,
8639
+ openWorldHint: false
8256
8640
  },
8257
8641
  async ({ plan_id, posts, project_id, response_format }) => {
8258
8642
  const supabase = getSupabaseClient();
@@ -8332,8 +8716,15 @@ function registerPlanApprovalTools(server) {
8332
8716
  "List MCP-native approval items for a specific content plan.",
8333
8717
  {
8334
8718
  plan_id: z19.string().uuid().describe("Content plan ID"),
8335
- status: z19.enum(["pending", "approved", "rejected", "edited"]).optional(),
8336
- response_format: z19.enum(["text", "json"]).optional()
8719
+ status: z19.enum(["pending", "approved", "rejected", "edited"]).optional().describe("Filter approvals by status. Omit to return all statuses."),
8720
+ response_format: z19.enum(["text", "json"]).optional().describe("Response format. Defaults to text.")
8721
+ },
8722
+ {
8723
+ title: "List Plan Approvals",
8724
+ readOnlyHint: true,
8725
+ destructiveHint: false,
8726
+ idempotentHint: true,
8727
+ openWorldHint: false
8337
8728
  },
8338
8729
  async ({ plan_id, status, response_format }) => {
8339
8730
  const supabase = getSupabaseClient();
@@ -8400,10 +8791,19 @@ function registerPlanApprovalTools(server) {
8400
8791
  "Approve, reject, or edit a pending plan approval item.",
8401
8792
  {
8402
8793
  approval_id: z19.string().uuid().describe("Approval item ID"),
8403
- decision: z19.enum(["approved", "rejected", "edited"]),
8404
- edited_post: z19.record(z19.string(), z19.unknown()).optional(),
8405
- reason: z19.string().max(1e3).optional(),
8406
- response_format: z19.enum(["text", "json"]).optional()
8794
+ decision: z19.enum(["approved", "rejected", "edited"]).describe("Approval decision for this post."),
8795
+ edited_post: z19.record(z19.string(), z19.unknown()).optional().describe(
8796
+ 'Revised post fields when decision is "edited" (e.g. {caption: "...", hashtags: [...]}).'
8797
+ ),
8798
+ reason: z19.string().max(1e3).optional().describe("Optional reason for the decision, visible to the plan author."),
8799
+ response_format: z19.enum(["text", "json"]).optional().describe("Response format. Defaults to text.")
8800
+ },
8801
+ {
8802
+ title: "Respond to Plan Approval",
8803
+ readOnlyHint: false,
8804
+ destructiveHint: false,
8805
+ idempotentHint: true,
8806
+ openWorldHint: false
8407
8807
  },
8408
8808
  async ({ approval_id, decision, edited_post, reason, response_format }) => {
8409
8809
  const supabase = getSupabaseClient();
@@ -8840,6 +9240,13 @@ function registerDiscoveryTools(server) {
8840
9240
  'Detail level: "name" for just tool names, "summary" for names + descriptions, "full" for complete info including scope and module'
8841
9241
  )
8842
9242
  },
9243
+ {
9244
+ title: "Search Tools",
9245
+ readOnlyHint: true,
9246
+ destructiveHint: false,
9247
+ idempotentHint: true,
9248
+ openWorldHint: false
9249
+ },
8843
9250
  async ({ query, module, scope, detail }) => {
8844
9251
  let results = [...TOOL_CATALOG];
8845
9252
  if (query) {