@socialneuron/mcp-server 1.4.2 → 1.5.1

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/index.js CHANGED
@@ -14,7 +14,7 @@ var MCP_VERSION;
14
14
  var init_version = __esm({
15
15
  "src/lib/version.ts"() {
16
16
  "use strict";
17
- MCP_VERSION = "1.4.2";
17
+ MCP_VERSION = "1.5.1";
18
18
  }
19
19
  });
20
20
 
@@ -400,7 +400,9 @@ async function getDefaultUserId() {
400
400
  if (authenticatedUserId) return authenticatedUserId;
401
401
  const envUserId = process.env.SOCIALNEURON_USER_ID;
402
402
  if (envUserId) return envUserId;
403
- throw new Error("No user ID available. Set SOCIALNEURON_USER_ID or authenticate via API key.");
403
+ throw new Error(
404
+ "No user ID available. Set SOCIALNEURON_USER_ID or authenticate via API key."
405
+ );
404
406
  }
405
407
  async function getDefaultProjectId() {
406
408
  const userId = await getDefaultUserId().catch(() => null);
@@ -444,7 +446,9 @@ async function initializeAuth() {
444
446
  console.error("[MCP] Scopes: " + authenticatedScopes.join(", "));
445
447
  if (authenticatedExpiresAt) {
446
448
  const expiresMs = new Date(authenticatedExpiresAt).getTime();
447
- const daysLeft = Math.ceil((expiresMs - Date.now()) / (1e3 * 60 * 60 * 24));
449
+ const daysLeft = Math.ceil(
450
+ (expiresMs - Date.now()) / (1e3 * 60 * 60 * 24)
451
+ );
448
452
  console.error("[MCP] Key expires: " + authenticatedExpiresAt);
449
453
  if (daysLeft <= 7) {
450
454
  console.error(
@@ -467,8 +471,12 @@ async function initializeAuth() {
467
471
  console.error(
468
472
  "[MCP] \u26A0 DEPRECATED: Service role keys grant full admin access to your database."
469
473
  );
470
- console.error("[MCP] Migrate to API key auth: npx @socialneuron/mcp-server setup");
471
- console.error("[MCP] Then remove SOCIALNEURON_SERVICE_KEY from your environment.");
474
+ console.error(
475
+ "[MCP] Migrate to API key auth: npx @socialneuron/mcp-server setup"
476
+ );
477
+ console.error(
478
+ "[MCP] Then remove SOCIALNEURON_SERVICE_KEY from your environment."
479
+ );
472
480
  if (!process.env.SOCIALNEURON_USER_ID) {
473
481
  console.error(
474
482
  "[MCP] Warning: SOCIALNEURON_USER_ID not set. Tools requiring a user will fail."
@@ -2947,7 +2955,9 @@ __export(setup_exports, {
2947
2955
  runSetup: () => runSetup
2948
2956
  });
2949
2957
  import { createHash as createHash4, randomBytes, randomUUID as randomUUID3 } from "node:crypto";
2950
- import { createServer } from "node:http";
2958
+ import {
2959
+ createServer
2960
+ } from "node:http";
2951
2961
  import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
2952
2962
  import { homedir as homedir3, platform as platform2 } from "node:os";
2953
2963
  import { join as join3 } from "node:path";
@@ -2962,7 +2972,7 @@ function generatePKCE() {
2962
2972
  return { codeVerifier, codeChallenge };
2963
2973
  }
2964
2974
  function getAppBaseUrl() {
2965
- return process.env.SOCIALNEURON_APP_URL || "https://app.socialneuron.com";
2975
+ return process.env.SOCIALNEURON_APP_URL || "https://www.socialneuron.com";
2966
2976
  }
2967
2977
  function getDefaultSupabaseUrl() {
2968
2978
  return process.env.SOCIALNEURON_SUPABASE_URL || process.env.SUPABASE_URL || CLOUD_SUPABASE_URL;
@@ -3033,11 +3043,14 @@ function readBody(req) {
3033
3043
  async function completePkceExchange(codeVerifier, state) {
3034
3044
  const supabaseUrl = getDefaultSupabaseUrl();
3035
3045
  try {
3036
- const response = await fetch(`${supabaseUrl}/functions/v1/mcp-auth?action=exchange-key`, {
3037
- method: "POST",
3038
- headers: { "Content-Type": "application/json" },
3039
- body: JSON.stringify({ code_verifier: codeVerifier, state })
3040
- });
3046
+ const response = await fetch(
3047
+ `${supabaseUrl}/functions/v1/mcp-auth?action=exchange-key`,
3048
+ {
3049
+ method: "POST",
3050
+ headers: { "Content-Type": "application/json" },
3051
+ body: JSON.stringify({ code_verifier: codeVerifier, state })
3052
+ }
3053
+ );
3041
3054
  if (!response.ok) {
3042
3055
  const text = await response.text();
3043
3056
  console.error(` PKCE exchange failed: ${text}`);
@@ -3046,7 +3059,9 @@ async function completePkceExchange(codeVerifier, state) {
3046
3059
  const data = await response.json();
3047
3060
  return data.success === true;
3048
3061
  } catch (err) {
3049
- console.error(` PKCE exchange error: ${err instanceof Error ? err.message : String(err)}`);
3062
+ console.error(
3063
+ ` PKCE exchange error: ${err instanceof Error ? err.message : String(err)}`
3064
+ );
3050
3065
  return false;
3051
3066
  }
3052
3067
  }
@@ -3057,9 +3072,13 @@ async function runSetup() {
3057
3072
  console.error("");
3058
3073
  console.error(" Privacy Notice:");
3059
3074
  console.error(" - Your API key is stored locally in your OS keychain");
3060
- console.error(" - Tool invocations are logged for usage metering (no content stored)");
3075
+ console.error(
3076
+ " - Tool invocations are logged for usage metering (no content stored)"
3077
+ );
3061
3078
  console.error(" - Set DO_NOT_TRACK=1 to disable telemetry");
3062
- console.error(" - Data export/delete: https://app.socialneuron.com/settings");
3079
+ console.error(
3080
+ " - Data export/delete: https://www.socialneuron.com/settings"
3081
+ );
3063
3082
  console.error("");
3064
3083
  const { codeVerifier, codeChallenge } = generatePKCE();
3065
3084
  const state = randomUUID3();
@@ -3092,49 +3111,54 @@ async function runSetup() {
3092
3111
  console.error("");
3093
3112
  console.error(" Waiting for authorization (timeout: 120s)...");
3094
3113
  }
3095
- const result = await new Promise((resolve3) => {
3096
- const timeout = setTimeout(() => {
3097
- server2.close();
3098
- resolve3({ error: "Authorization timed out after 120 seconds." });
3099
- }, 12e4);
3100
- server2.on("request", async (req, res) => {
3101
- res.setHeader("Access-Control-Allow-Origin", "*");
3102
- res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
3103
- res.setHeader("Access-Control-Allow-Headers", "Content-Type");
3104
- if (req.method === "OPTIONS") {
3105
- res.writeHead(204);
3106
- res.end();
3107
- return;
3108
- }
3109
- if (req.method === "POST" && req.url === "/callback") {
3110
- try {
3111
- const body = await readBody(req);
3112
- const data = JSON.parse(body);
3113
- if (data.state !== state) {
3114
- res.writeHead(400, { "Content-Type": "application/json" });
3115
- res.end(JSON.stringify({ error: "State mismatch" }));
3114
+ const result = await new Promise(
3115
+ (resolve3) => {
3116
+ const timeout = setTimeout(() => {
3117
+ server2.close();
3118
+ resolve3({ error: "Authorization timed out after 120 seconds." });
3119
+ }, 12e4);
3120
+ server2.on(
3121
+ "request",
3122
+ async (req, res) => {
3123
+ res.setHeader("Access-Control-Allow-Origin", "*");
3124
+ res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
3125
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
3126
+ if (req.method === "OPTIONS") {
3127
+ res.writeHead(204);
3128
+ res.end();
3116
3129
  return;
3117
3130
  }
3118
- if (!data.api_key) {
3119
- res.writeHead(400, { "Content-Type": "application/json" });
3120
- res.end(JSON.stringify({ error: "Missing api_key" }));
3131
+ if (req.method === "POST" && req.url === "/callback") {
3132
+ try {
3133
+ const body = await readBody(req);
3134
+ const data = JSON.parse(body);
3135
+ if (data.state !== state) {
3136
+ res.writeHead(400, { "Content-Type": "application/json" });
3137
+ res.end(JSON.stringify({ error: "State mismatch" }));
3138
+ return;
3139
+ }
3140
+ if (!data.api_key) {
3141
+ res.writeHead(400, { "Content-Type": "application/json" });
3142
+ res.end(JSON.stringify({ error: "Missing api_key" }));
3143
+ return;
3144
+ }
3145
+ res.writeHead(200, { "Content-Type": "application/json" });
3146
+ res.end(JSON.stringify({ success: true }));
3147
+ clearTimeout(timeout);
3148
+ server2.close();
3149
+ resolve3({ apiKey: data.api_key });
3150
+ } catch {
3151
+ res.writeHead(400, { "Content-Type": "application/json" });
3152
+ res.end(JSON.stringify({ error: "Invalid request" }));
3153
+ }
3121
3154
  return;
3122
3155
  }
3123
- res.writeHead(200, { "Content-Type": "application/json" });
3124
- res.end(JSON.stringify({ success: true }));
3125
- clearTimeout(timeout);
3126
- server2.close();
3127
- resolve3({ apiKey: data.api_key });
3128
- } catch {
3129
- res.writeHead(400, { "Content-Type": "application/json" });
3130
- res.end(JSON.stringify({ error: "Invalid request" }));
3156
+ res.writeHead(404);
3157
+ res.end("Not found");
3131
3158
  }
3132
- return;
3133
- }
3134
- res.writeHead(404);
3135
- res.end("Not found");
3136
- });
3137
- });
3159
+ );
3160
+ }
3161
+ );
3138
3162
  if ("error" in result) {
3139
3163
  console.error("");
3140
3164
  console.error(` Error: ${result.error}`);
@@ -3146,7 +3170,9 @@ async function runSetup() {
3146
3170
  const exchangeSuccess = await completePkceExchange(codeVerifier, state);
3147
3171
  if (!exchangeSuccess) {
3148
3172
  console.error(" Warning: PKCE exchange failed. Key may not be activated.");
3149
- console.error(" The key will still work if the server was in legacy mode.");
3173
+ console.error(
3174
+ " The key will still work if the server was in legacy mode."
3175
+ );
3150
3176
  } else {
3151
3177
  console.error(" PKCE verification complete.");
3152
3178
  }
@@ -3167,7 +3193,9 @@ async function runSetup() {
3167
3193
  }
3168
3194
  if (!configured) {
3169
3195
  console.error("");
3170
- console.error(" No MCP client config found. Add this to your MCP config manually:");
3196
+ console.error(
3197
+ " No MCP client config found. Add this to your MCP config manually:"
3198
+ );
3171
3199
  console.error("");
3172
3200
  console.error(' "socialneuron": {');
3173
3201
  console.error(' "command": "npx",');
@@ -3870,10 +3898,10 @@ init_supabase();
3870
3898
  function registerIdeationTools(server2) {
3871
3899
  server2.tool(
3872
3900
  "generate_content",
3873
- "Generate AI-powered content (scripts, captions, hooks, blog posts) using Google Gemini or Anthropic Claude. Provide a detailed prompt describing what you need, choose the content type, and optionally specify a target platform and brand voice guidelines.",
3901
+ "Create a script, caption, hook, or blog post tailored to a specific platform. Pass project_id to auto-load brand profile and performance context, or call get_ideation_context first for full context. Output is draft text ready for quality_check then schedule_post.",
3874
3902
  {
3875
3903
  prompt: z.string().max(1e4).describe(
3876
- "Detailed prompt describing the content to generate. Include context like topic, angle, audience, and any specific requirements."
3904
+ 'Detailed content prompt. Include topic, angle, audience, and requirements. Example: "LinkedIn post about AI productivity for CTOs, 300 words, include 3 actionable tips, conversational tone." Richer prompts produce better results.'
3877
3905
  ),
3878
3906
  content_type: z.enum(["script", "caption", "blog", "hook"]).describe(
3879
3907
  'Type of content to generate. "script" for video scripts, "caption" for social media captions, "blog" for blog posts, "hook" for attention-grabbing hooks.'
@@ -3891,7 +3919,7 @@ function registerIdeationTools(server2) {
3891
3919
  "Target social media platform. Helps tailor tone, length, and format."
3892
3920
  ),
3893
3921
  brand_voice: z.string().max(500).optional().describe(
3894
- 'Brand voice guidelines to follow (e.g. "professional and empathetic", "playful and Gen-Z"). Leave blank to use a neutral tone.'
3922
+ 'Tone directive (e.g. "direct, no jargon, second person" or "witty Gen-Z energy with emoji"). Leave blank to auto-load from project brand profile if project_id is set.'
3895
3923
  ),
3896
3924
  model: z.enum(["gemini-2.0-flash", "gemini-2.5-flash", "gemini-2.5-pro"]).optional().describe(
3897
3925
  "AI model to use. Defaults to gemini-2.5-flash. Use gemini-2.5-pro for highest quality."
@@ -3900,6 +3928,13 @@ function registerIdeationTools(server2) {
3900
3928
  "Project ID to auto-load brand profile and performance context for prompt enrichment."
3901
3929
  )
3902
3930
  },
3931
+ {
3932
+ title: "Generate Content",
3933
+ readOnlyHint: false,
3934
+ destructiveHint: false,
3935
+ idempotentHint: false,
3936
+ openWorldHint: true
3937
+ },
3903
3938
  async ({
3904
3939
  prompt: prompt2,
3905
3940
  content_type,
@@ -4047,7 +4082,7 @@ Content Type: ${content_type}`;
4047
4082
  );
4048
4083
  server2.tool(
4049
4084
  "fetch_trends",
4050
- "Fetch current trending topics from YouTube, Google Trends, RSS feeds, or a custom URL. Results are cached for efficiency. Use this to discover what is popular right now for content ideation.",
4085
+ 'Get current trending topics for content inspiration. Source "youtube" returns trending videos with view counts, "google_trends" returns rising search terms, "rss"/"url" extracts topics from any feed or page. Results cached 1 hour \u2014 set force_refresh=true for real-time. Feed results into generate_content or plan_content_week.',
4051
4086
  {
4052
4087
  source: z.enum(["youtube", "google_trends", "rss", "url"]).describe(
4053
4088
  'Data source. "youtube" fetches trending videos, "google_trends" fetches daily search trends, "rss" fetches from a custom RSS feed URL, "url" extracts trend data from a web page.'
@@ -4063,6 +4098,13 @@ Content Type: ${content_type}`;
4063
4098
  ),
4064
4099
  force_refresh: z.boolean().optional().describe("Skip the server-side cache and fetch fresh data.")
4065
4100
  },
4101
+ {
4102
+ title: "Fetch Trends",
4103
+ readOnlyHint: true,
4104
+ destructiveHint: false,
4105
+ idempotentHint: false,
4106
+ openWorldHint: true
4107
+ },
4066
4108
  async ({ source, category, niche, url, force_refresh }) => {
4067
4109
  if ((source === "rss" || source === "url") && !url) {
4068
4110
  return {
@@ -4133,7 +4175,7 @@ Content Type: ${content_type}`;
4133
4175
  );
4134
4176
  server2.tool(
4135
4177
  "adapt_content",
4136
- "Adapt existing content for a different social media platform. Rewrites content to match the target platform's norms including character limits, hashtag style, tone, and CTA conventions.",
4178
+ "Rewrite existing content for a different platform \u2014 adjusts character limits, hashtag style, tone, and CTA format automatically. Use after generate_content when you need the same message across multiple platforms. Pass project_id to apply platform-specific voice overrides from your brand profile.",
4137
4179
  {
4138
4180
  content: z.string().max(5e3).describe(
4139
4181
  "The content to adapt. Can be a caption, script, blog excerpt, or any text."
@@ -4167,6 +4209,13 @@ Content Type: ${content_type}`;
4167
4209
  "Optional project ID to load platform voice overrides from brand profile."
4168
4210
  )
4169
4211
  },
4212
+ {
4213
+ title: "Adapt Content",
4214
+ readOnlyHint: false,
4215
+ destructiveHint: false,
4216
+ idempotentHint: false,
4217
+ openWorldHint: true
4218
+ },
4170
4219
  async ({
4171
4220
  content,
4172
4221
  source_platform,
@@ -4431,10 +4480,10 @@ function checkAssetBudget() {
4431
4480
  function registerContentTools(server2) {
4432
4481
  server2.tool(
4433
4482
  "generate_video",
4434
- "Start an AI video generation job. This is an async operation -- it returns a job_id immediately. Use check_status to poll for completion. Supports Veo 3, Runway, Sora 2, Kling 2.6, and Kling 3.0 models via the Kie.ai API.",
4483
+ "Start an async AI video generation job \u2014 returns a job_id immediately. Poll with check_status every 10-30s until complete. Cost varies by model: veo3-fast (~15 credits/5s), kling-3 (~30 credits/5s), sora2-pro (~60 credits/10s). Check get_credit_balance first for expensive generations.",
4435
4484
  {
4436
4485
  prompt: z2.string().max(2500).describe(
4437
- "Text prompt describing the video to generate. Be specific about visual style, camera angles, movement, lighting, and mood."
4486
+ 'Video prompt \u2014 be specific about visual style, camera movement, lighting, and mood. Example: "Aerial drone shot of coastal cliffs at golden hour, slow dolly forward, cinematic 24fps, warm color grading." Vague prompts produce generic results.'
4438
4487
  ),
4439
4488
  model: z2.enum([
4440
4489
  "veo3-fast",
@@ -4446,13 +4495,13 @@ function registerContentTools(server2) {
4446
4495
  "kling-3",
4447
4496
  "kling-3-pro"
4448
4497
  ]).describe(
4449
- "Video generation model. veo3-fast is fastest (~60s), veo3-quality is highest quality (~120s). sora2-pro is OpenAI Sora premium tier. kling-3 is Kling 3.0 standard (4K, 15s, audio). kling-3-pro is Kling 3.0 pro (higher quality)."
4498
+ "Video model. veo3-fast: fastest (~15 credits/5s, ~60s render). veo3-quality: highest quality (~20 credits/5s, ~120s). sora2-pro: OpenAI premium (~60 credits/10s). kling-3: 4K with audio (~30 credits/5s). kling-3-pro: best Kling quality (~40 credits/5s)."
4450
4499
  ),
4451
4500
  duration: z2.number().min(3).max(30).optional().describe(
4452
4501
  "Video duration in seconds. kling: 5-30s, kling-3/kling-3-pro: 3-15s, sora2: 10-15s. Defaults to 5 seconds."
4453
4502
  ),
4454
4503
  aspect_ratio: z2.enum(["16:9", "9:16", "1:1"]).optional().describe(
4455
- "Aspect ratio. 16:9 for landscape/YouTube, 9:16 for vertical/Reels/TikTok, 1:1 for square. Defaults to 16:9."
4504
+ "Video aspect ratio. 16:9 for YouTube/landscape, 9:16 for TikTok/Reels/Shorts, 1:1 for Instagram feed/square. Defaults to 16:9."
4456
4505
  ),
4457
4506
  enable_audio: z2.boolean().optional().describe(
4458
4507
  "Enable native audio generation. Kling 2.6: doubles cost. Kling 3.0: 50% more (std 30/sec, pro 40/sec). 5+ languages."
@@ -4465,6 +4514,13 @@ function registerContentTools(server2) {
4465
4514
  ),
4466
4515
  response_format: z2.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
4467
4516
  },
4517
+ {
4518
+ title: "Generate Video",
4519
+ readOnlyHint: false,
4520
+ destructiveHint: false,
4521
+ idempotentHint: false,
4522
+ openWorldHint: true
4523
+ },
4468
4524
  async ({
4469
4525
  prompt: prompt2,
4470
4526
  model,
@@ -4639,7 +4695,7 @@ function registerContentTools(server2) {
4639
4695
  );
4640
4696
  server2.tool(
4641
4697
  "generate_image",
4642
- "Start an AI image generation job. This is an async operation -- it returns a job_id immediately. Use check_status to poll for completion. Supports Midjourney, Imagen 4, Flux, GPT-4o Image, and Seedream models.",
4698
+ "Start an async AI image generation job \u2014 returns a job_id immediately. Poll with check_status every 5-15s until complete. Costs 2-10 credits depending on model. Use for social media posts, carousel slides, or as input to generate_video (image-to-video).",
4643
4699
  {
4644
4700
  prompt: z2.string().max(2e3).describe(
4645
4701
  "Text prompt describing the image to generate. Be specific about style, composition, colors, lighting, and subject matter."
@@ -4663,6 +4719,13 @@ function registerContentTools(server2) {
4663
4719
  ),
4664
4720
  response_format: z2.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
4665
4721
  },
4722
+ {
4723
+ title: "Generate Image",
4724
+ readOnlyHint: false,
4725
+ destructiveHint: false,
4726
+ idempotentHint: false,
4727
+ openWorldHint: true
4728
+ },
4666
4729
  async ({ prompt: prompt2, model, aspect_ratio, image_url, response_format }) => {
4667
4730
  const format = response_format ?? "text";
4668
4731
  const startedAt = Date.now();
@@ -4819,13 +4882,20 @@ function registerContentTools(server2) {
4819
4882
  );
4820
4883
  server2.tool(
4821
4884
  "check_status",
4822
- "Check the status of an async generation job (video or image). Returns the current status, progress percentage, and result URL when complete. Call this tool periodically after generate_video or generate_image.",
4885
+ 'Poll an async job started by generate_video or generate_image. Returns status (queued/processing/completed/failed), progress %, and result URL on completion. Poll every 10-30s for video, 5-15s for images. On "failed" status, the error field explains why \u2014 check credits or try a different model.',
4823
4886
  {
4824
4887
  job_id: z2.string().describe(
4825
4888
  "The job ID returned by generate_video or generate_image. This is the asyncJobId or taskId value."
4826
4889
  ),
4827
4890
  response_format: z2.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
4828
4891
  },
4892
+ {
4893
+ title: "Check Job Status",
4894
+ readOnlyHint: true,
4895
+ destructiveHint: false,
4896
+ idempotentHint: true,
4897
+ openWorldHint: true
4898
+ },
4829
4899
  async ({ job_id, response_format }) => {
4830
4900
  const format = response_format ?? "text";
4831
4901
  const startedAt = Date.now();
@@ -4995,7 +5065,7 @@ function registerContentTools(server2) {
4995
5065
  );
4996
5066
  server2.tool(
4997
5067
  "create_storyboard",
4998
- "Generate a structured scene-by-scene storyboard for video production. Returns an array of StoryboardFrames with prompts, durations, captions, and voiceover text. Use the output to drive image/video generation.",
5068
+ "Plan a multi-scene video storyboard with AI-generated prompts, durations, captions, and voiceover text per frame. Use before generate_video or generate_image to create cohesive multi-shot content. Include brand_context from get_brand_profile for consistent visual branding across frames.",
4999
5069
  {
5000
5070
  concept: z2.string().max(2e3).describe(
5001
5071
  'The video concept/idea. Include: hook, key messages, target audience, and desired outcome (e.g., "TikTok ad for VPN app targeting privacy-conscious millennials, hook with shocking stat about data leaks").'
@@ -5023,6 +5093,13 @@ function registerContentTools(server2) {
5023
5093
  "Response format. Defaults to json for structured storyboard data."
5024
5094
  )
5025
5095
  },
5096
+ {
5097
+ title: "Create Storyboard",
5098
+ readOnlyHint: false,
5099
+ destructiveHint: false,
5100
+ idempotentHint: false,
5101
+ openWorldHint: true
5102
+ },
5026
5103
  async ({
5027
5104
  concept,
5028
5105
  brand_context,
@@ -5182,7 +5259,7 @@ Return ONLY valid JSON in this exact format:
5182
5259
  );
5183
5260
  server2.tool(
5184
5261
  "generate_voiceover",
5185
- "Generate a professional voiceover audio file using ElevenLabs TTS. Returns an audio URL stored in R2. Use this for narration in video production.",
5262
+ "Generate a voiceover audio file for video narration. Returns an R2-hosted audio URL. Use after create_storyboard to add narration to each scene, or standalone for podcast intros and ad reads. Costs ~2 credits per generation.",
5186
5263
  {
5187
5264
  text: z2.string().max(5e3).describe("The script/text to convert to speech."),
5188
5265
  voice: z2.enum([
@@ -5203,6 +5280,13 @@ Return ONLY valid JSON in this exact format:
5203
5280
  speed: z2.number().min(0.5).max(2).optional().describe("Speech speed multiplier. 1.0 is normal. Defaults to 1.0."),
5204
5281
  response_format: z2.enum(["text", "json"]).optional().describe("Response format. Defaults to text.")
5205
5282
  },
5283
+ {
5284
+ title: "Generate Voiceover",
5285
+ readOnlyHint: false,
5286
+ destructiveHint: false,
5287
+ idempotentHint: false,
5288
+ openWorldHint: true
5289
+ },
5206
5290
  async ({ text, voice, speed, response_format }) => {
5207
5291
  const format = response_format ?? "text";
5208
5292
  const startedAt = Date.now();
@@ -5333,10 +5417,10 @@ Return ONLY valid JSON in this exact format:
5333
5417
  );
5334
5418
  server2.tool(
5335
5419
  "generate_carousel",
5336
- "Generate an Instagram carousel with AI-powered slide content. Supports multiple templates including Hormozi-style authority carousels. Returns slide data (headlines, body text, emphasis words). Use schedule_post with media_urls to publish the carousel to Instagram.",
5420
+ "Generate carousel slide content (headlines, body text, emphasis words per slide). Supports Hormozi-style authority format and educational templates. Returns structured slide data \u2014 render visually then publish via schedule_post with media_type=CAROUSEL_ALBUM and 2-10 media_urls on Instagram.",
5337
5421
  {
5338
5422
  topic: z2.string().max(200).describe(
5339
- 'The carousel topic/subject. Be specific about the angle or hook. Example: "5 reasons your startup will fail in 2026"'
5423
+ 'Carousel hook/angle \u2014 specific beats general. Example: "5 pricing mistakes that kill SaaS startups" beats "SaaS tips". Include a curiosity gap or strong opinion for better Hook Strength scores.'
5340
5424
  ),
5341
5425
  template_id: z2.enum([
5342
5426
  "educational-series",
@@ -5361,6 +5445,13 @@ Return ONLY valid JSON in this exact format:
5361
5445
  project_id: z2.string().optional().describe("Project ID to associate the carousel with."),
5362
5446
  response_format: z2.enum(["text", "json"]).optional().describe("Response format. Defaults to json.")
5363
5447
  },
5448
+ {
5449
+ title: "Generate Carousel",
5450
+ readOnlyHint: false,
5451
+ destructiveHint: false,
5452
+ idempotentHint: false,
5453
+ openWorldHint: true
5454
+ },
5364
5455
  async ({
5365
5456
  topic,
5366
5457
  template_id,
@@ -5543,14 +5634,12 @@ function asEnvelope2(data) {
5543
5634
  function registerDistributionTools(server2) {
5544
5635
  server2.tool(
5545
5636
  "schedule_post",
5546
- "Schedule or immediately publish a post to one or more social media platforms. Requires the target platforms to have active OAuth connections configured in Social Neuron Settings. Supports YouTube, TikTok, Instagram, Facebook, LinkedIn, Twitter, Threads, and Bluesky. For Instagram carousels, provide media_urls (2-10 image URLs) and set media_type to CAROUSEL_ALBUM.",
5637
+ 'Publish or schedule a post to connected social platforms. Check list_connected_accounts first to verify active OAuth for each target platform. For Instagram carousels: use media_type=CAROUSEL_ALBUM with 2-10 media_urls. For YouTube: title is required. schedule_at uses ISO 8601 (e.g. "2026-03-20T14:00:00Z") \u2014 omit to post immediately.',
5547
5638
  {
5548
5639
  media_url: z3.string().optional().describe(
5549
5640
  "Optional URL of the media file (video or image) to post. This should be a publicly accessible URL or a Cloudflare R2 signed URL from a previous generation. Required for platforms that enforce media uploads. Not needed if media_urls is provided."
5550
5641
  ),
5551
- media_urls: z3.array(z3.string()).optional().describe(
5552
- "Array of image URLs for Instagram carousel posts (2-10 images). Each URL should be publicly accessible or a Cloudflare R2 URL. When provided with media_type=CAROUSEL_ALBUM, creates an Instagram carousel."
5553
- ),
5642
+ media_urls: z3.array(z3.string()).optional().describe("Array of 2-10 image URLs for Instagram carousel posts. Each URL must be publicly accessible or a Cloudflare R2 signed URL. Use with media_type=CAROUSEL_ALBUM."),
5554
5643
  media_type: z3.enum(["IMAGE", "VIDEO", "CAROUSEL_ALBUM"]).optional().describe(
5555
5644
  "Media type. Set to CAROUSEL_ALBUM with media_urls for Instagram carousels. Default: auto-detected from media_url."
5556
5645
  ),
@@ -5566,22 +5655,23 @@ function registerDistributionTools(server2) {
5566
5655
  "threads",
5567
5656
  "bluesky"
5568
5657
  ])
5569
- ).min(1).describe(
5570
- "Target platforms to post to. Each must have an active OAuth connection."
5571
- ),
5658
+ ).min(1).describe("Target platforms (array). Each must have active OAuth \u2014 check list_connected_accounts first. Values: youtube, tiktok, instagram, twitter, linkedin, facebook, threads, bluesky."),
5572
5659
  title: z3.string().optional().describe("Post title (used by YouTube and some other platforms)."),
5573
- hashtags: z3.array(z3.string()).optional().describe(
5574
- 'Hashtags to append to the caption. Include or omit the "#" prefix.'
5575
- ),
5576
- schedule_at: z3.string().optional().describe(
5577
- 'ISO 8601 datetime for scheduled posting (e.g. "2026-03-15T14:00:00Z"). Omit for immediate posting.'
5578
- ),
5660
+ hashtags: z3.array(z3.string()).optional().describe('Hashtags to append to caption. Include or omit the "#" prefix \u2014 both work. Example: ["ai", "contentcreator"] or ["#ai", "#contentcreator"].'),
5661
+ schedule_at: z3.string().optional().describe('ISO 8601 UTC datetime for scheduled posting (e.g. "2026-03-20T14:00:00Z"). Omit to post immediately. Must be in the future.'),
5579
5662
  project_id: z3.string().optional().describe("Social Neuron project ID to associate this post with."),
5580
5663
  response_format: z3.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text."),
5581
5664
  attribution: z3.boolean().optional().describe(
5582
5665
  'If true, appends "Created with Social Neuron" to the caption. Default: false.'
5583
5666
  )
5584
5667
  },
5668
+ {
5669
+ title: "Schedule Post",
5670
+ readOnlyHint: false,
5671
+ destructiveHint: false,
5672
+ idempotentHint: false,
5673
+ openWorldHint: true
5674
+ },
5585
5675
  async ({
5586
5676
  media_url,
5587
5677
  media_urls,
@@ -5729,10 +5819,17 @@ Created with Social Neuron`;
5729
5819
  );
5730
5820
  server2.tool(
5731
5821
  "list_connected_accounts",
5732
- "List all social media accounts connected to Social Neuron via OAuth. Shows which platforms are available for posting.",
5822
+ "Check which social platforms have active OAuth connections for posting. Call this before schedule_post to verify credentials. If a platform is missing or expired, the user needs to reconnect at socialneuron.com/settings/connections.",
5733
5823
  {
5734
5824
  response_format: z3.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
5735
5825
  },
5826
+ {
5827
+ title: "List Connected Accounts",
5828
+ readOnlyHint: true,
5829
+ destructiveHint: false,
5830
+ idempotentHint: true,
5831
+ openWorldHint: false
5832
+ },
5736
5833
  async ({ response_format }) => {
5737
5834
  const format = response_format ?? "text";
5738
5835
  const supabase = getSupabaseClient();
@@ -5794,7 +5891,7 @@ Created with Social Neuron`;
5794
5891
  );
5795
5892
  server2.tool(
5796
5893
  "list_recent_posts",
5797
- "List recent posts from Social Neuron. Shows status, platform, title, and timestamps. Useful for checking what has been published or scheduled recently.",
5894
+ "List recent published and scheduled posts with status, platform, title, and timestamps. Use to check what has been posted before planning new content, or to find post IDs for fetch_analytics. Filter by platform or status to narrow results.",
5798
5895
  {
5799
5896
  platform: z3.enum([
5800
5897
  "youtube",
@@ -5811,6 +5908,13 @@ Created with Social Neuron`;
5811
5908
  limit: z3.number().min(1).max(50).optional().describe("Maximum number of posts to return. Defaults to 20."),
5812
5909
  response_format: z3.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
5813
5910
  },
5911
+ {
5912
+ title: "List Recent Posts",
5913
+ readOnlyHint: true,
5914
+ destructiveHint: false,
5915
+ idempotentHint: true,
5916
+ openWorldHint: false
5917
+ },
5814
5918
  async ({ platform: platform3, status, days, limit, response_format }) => {
5815
5919
  const format = response_format ?? "text";
5816
5920
  const supabase = getSupabaseClient();
@@ -5910,7 +6014,7 @@ Created with Social Neuron`;
5910
6014
  };
5911
6015
  server2.tool(
5912
6016
  "find_next_slots",
5913
- "Find optimal posting time slots based on best posting times and existing schedule. Returns non-conflicting slots sorted by engagement score.",
6017
+ "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.",
5914
6018
  {
5915
6019
  platforms: z3.array(
5916
6020
  z3.enum([
@@ -5923,11 +6027,18 @@ Created with Social Neuron`;
5923
6027
  "threads",
5924
6028
  "bluesky"
5925
6029
  ])
5926
- ).min(1),
6030
+ ).min(1).describe("Platforms to find posting slots for."),
5927
6031
  count: z3.number().min(1).max(20).default(7).describe("Number of slots to find"),
5928
6032
  start_after: z3.string().optional().describe("ISO datetime, defaults to now"),
5929
6033
  min_gap_hours: z3.number().min(1).max(24).default(4).describe("Minimum gap between posts on same platform"),
5930
- response_format: z3.enum(["text", "json"]).default("text")
6034
+ response_format: z3.enum(["text", "json"]).default("text").describe("Response format. Defaults to text.")
6035
+ },
6036
+ {
6037
+ title: "Find Next Posting Slots",
6038
+ readOnlyHint: true,
6039
+ destructiveHint: false,
6040
+ idempotentHint: true,
6041
+ openWorldHint: false
5931
6042
  },
5932
6043
  async ({
5933
6044
  platforms,
@@ -6047,20 +6158,20 @@ Created with Social Neuron`;
6047
6158
  plan: z3.object({
6048
6159
  posts: z3.array(
6049
6160
  z3.object({
6050
- id: z3.string(),
6051
- caption: z3.string(),
6052
- platform: z3.string(),
6053
- title: z3.string().optional(),
6054
- media_url: z3.string().optional(),
6055
- schedule_at: z3.string().optional(),
6056
- hashtags: z3.array(z3.string()).optional()
6161
+ id: z3.string().describe("Unique post identifier from the content plan."),
6162
+ caption: z3.string().describe("Post caption/body text."),
6163
+ platform: z3.string().describe("Target platform name (e.g. instagram, youtube)."),
6164
+ title: z3.string().optional().describe("Post title, required for YouTube."),
6165
+ media_url: z3.string().optional().describe("Public or R2 signed URL for the post media."),
6166
+ schedule_at: z3.string().optional().describe("ISO 8601 UTC datetime to publish (e.g. 2026-03-20T14:00:00Z)."),
6167
+ hashtags: z3.array(z3.string()).optional().describe("Hashtags to append to the caption.")
6057
6168
  })
6058
6169
  )
6059
- }).passthrough().optional(),
6170
+ }).passthrough().optional().describe("Inline content plan object with a posts array. Provide this or plan_id."),
6060
6171
  plan_id: z3.string().uuid().optional().describe("Persisted content plan ID from content_plans table"),
6061
6172
  auto_slot: z3.boolean().default(true).describe("Auto-assign time slots for posts without schedule_at"),
6062
6173
  dry_run: z3.boolean().default(false).describe("Preview without actually scheduling"),
6063
- response_format: z3.enum(["text", "json"]).default("text"),
6174
+ response_format: z3.enum(["text", "json"]).default("text").describe("Response format. Defaults to text."),
6064
6175
  enforce_quality: z3.boolean().default(true).describe(
6065
6176
  "When true, block scheduling for posts that fail quality checks."
6066
6177
  ),
@@ -6070,6 +6181,13 @@ Created with Social Neuron`;
6070
6181
  batch_size: z3.number().int().min(1).max(10).default(4).describe("Concurrent schedule calls per platform batch."),
6071
6182
  idempotency_seed: z3.string().max(128).optional().describe("Optional stable seed used when building idempotency keys.")
6072
6183
  },
6184
+ {
6185
+ title: "Schedule Content Plan",
6186
+ readOnlyHint: false,
6187
+ destructiveHint: false,
6188
+ idempotentHint: false,
6189
+ openWorldHint: true
6190
+ },
6073
6191
  async ({
6074
6192
  plan,
6075
6193
  plan_id,
@@ -6635,13 +6753,14 @@ function registerAnalyticsTools(server2) {
6635
6753
  "threads",
6636
6754
  "bluesky"
6637
6755
  ]).optional().describe("Filter analytics to a specific platform."),
6638
- days: z4.number().min(1).max(365).optional().describe("Number of days to look back. Defaults to 30. Max 365."),
6756
+ days: z4.number().min(1).max(365).optional().describe("Lookback window in days (1-365). Default 30. Use 7 for weekly review, 30 for monthly summary, 90 for quarterly trends."),
6639
6757
  content_id: z4.string().uuid().optional().describe(
6640
6758
  "Filter to a specific content_history ID to see performance of one piece of content."
6641
6759
  ),
6642
6760
  limit: z4.number().min(1).max(100).optional().describe("Maximum number of posts to return. Defaults to 20."),
6643
6761
  response_format: z4.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
6644
6762
  },
6763
+ { title: "Fetch Analytics", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
6645
6764
  async ({ platform: platform3, days, content_id, limit, response_format }) => {
6646
6765
  const format = response_format ?? "text";
6647
6766
  const supabase = getSupabaseClient();
@@ -6815,10 +6934,17 @@ function registerAnalyticsTools(server2) {
6815
6934
  );
6816
6935
  server2.tool(
6817
6936
  "refresh_platform_analytics",
6818
- "Trigger an analytics refresh for all recently posted content across all connected platforms. Queues analytics fetch jobs for posts from the last 7 days.",
6937
+ "Queue analytics refresh jobs for all posts from the last 7 days across connected platforms. Call this before fetch_analytics if you need fresh data. Returns immediately \u2014 data updates asynchronously over the next 1-5 minutes.",
6819
6938
  {
6820
6939
  response_format: z4.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
6821
6940
  },
6941
+ {
6942
+ title: "Refresh Platform Analytics",
6943
+ readOnlyHint: false,
6944
+ destructiveHint: false,
6945
+ idempotentHint: false,
6946
+ openWorldHint: true
6947
+ },
6822
6948
  async ({ response_format }) => {
6823
6949
  const format = response_format ?? "text";
6824
6950
  const startedAt = Date.now();
@@ -7220,6 +7346,13 @@ function registerBrandTools(server2) {
7220
7346
  ),
7221
7347
  response_format: z5.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
7222
7348
  },
7349
+ {
7350
+ title: "Extract Brand",
7351
+ readOnlyHint: true,
7352
+ destructiveHint: false,
7353
+ idempotentHint: true,
7354
+ openWorldHint: true
7355
+ },
7223
7356
  async ({ url, response_format }) => {
7224
7357
  const ssrfCheck = await validateUrlForSSRF(url);
7225
7358
  if (!ssrfCheck.isValid) {
@@ -7303,6 +7436,13 @@ function registerBrandTools(server2) {
7303
7436
  project_id: z5.string().uuid().optional().describe("Project ID. Defaults to active project context."),
7304
7437
  response_format: z5.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
7305
7438
  },
7439
+ {
7440
+ title: "Get Brand Profile",
7441
+ readOnlyHint: true,
7442
+ destructiveHint: false,
7443
+ idempotentHint: true,
7444
+ openWorldHint: false
7445
+ },
7306
7446
  async ({ project_id, response_format }) => {
7307
7447
  const supabase = getSupabaseClient();
7308
7448
  const userId = await getDefaultUserId();
@@ -7400,8 +7540,17 @@ function registerBrandTools(server2) {
7400
7540
  "product_showcase"
7401
7541
  ]).optional().describe("Extraction method metadata."),
7402
7542
  overall_confidence: z5.number().min(0).max(1).optional().describe("Optional overall confidence score in range 0..1."),
7403
- extraction_metadata: z5.record(z5.string(), z5.unknown()).optional(),
7404
- response_format: z5.enum(["text", "json"]).optional()
7543
+ extraction_metadata: z5.record(z5.string(), z5.unknown()).optional().describe(
7544
+ "Arbitrary key-value metadata about the extraction process."
7545
+ ),
7546
+ response_format: z5.enum(["text", "json"]).optional().describe("Response format. Defaults to text.")
7547
+ },
7548
+ {
7549
+ title: "Save Brand Profile",
7550
+ readOnlyHint: false,
7551
+ destructiveHint: false,
7552
+ idempotentHint: false,
7553
+ openWorldHint: false
7405
7554
  },
7406
7555
  async ({
7407
7556
  project_id,
@@ -7514,15 +7663,32 @@ Version: ${payload.version ?? "N/A"}`
7514
7663
  "facebook",
7515
7664
  "threads",
7516
7665
  "bluesky"
7517
- ]),
7666
+ ]).describe("Social platform to set voice overrides for."),
7518
7667
  project_id: z5.string().uuid().optional().describe("Project ID. Defaults to active project context."),
7519
7668
  samples: z5.string().max(3e3).optional().describe("3-5 real platform post examples for style anchoring."),
7520
- tone: z5.array(z5.string()).optional(),
7521
- style: z5.array(z5.string()).optional(),
7522
- avoid_patterns: z5.array(z5.string()).optional(),
7523
- hashtag_strategy: z5.string().max(300).optional(),
7524
- cta_style: z5.string().max(300).optional(),
7525
- response_format: z5.enum(["text", "json"]).optional()
7669
+ tone: z5.array(z5.string()).optional().describe(
7670
+ 'Tone descriptors for this platform (e.g. ["casual", "witty", "informative"]).'
7671
+ ),
7672
+ style: z5.array(z5.string()).optional().describe(
7673
+ 'Writing style tags (e.g. ["short-form", "emoji-heavy", "storytelling"]).'
7674
+ ),
7675
+ avoid_patterns: z5.array(z5.string()).optional().describe(
7676
+ 'Phrases or patterns the brand should never use on this platform (e.g. ["click here", "buy now"]).'
7677
+ ),
7678
+ hashtag_strategy: z5.string().max(300).optional().describe(
7679
+ 'Hashtag usage guidelines for this platform (e.g. "3-5 niche hashtags, no generic tags").'
7680
+ ),
7681
+ cta_style: z5.string().max(300).optional().describe(
7682
+ 'Preferred call-to-action style (e.g. "soft CTA with question" or "direct link in bio").'
7683
+ ),
7684
+ response_format: z5.enum(["text", "json"]).optional().describe("Response format. Defaults to text.")
7685
+ },
7686
+ {
7687
+ title: "Update Platform Voice",
7688
+ readOnlyHint: false,
7689
+ destructiveHint: false,
7690
+ idempotentHint: false,
7691
+ openWorldHint: false
7526
7692
  },
7527
7693
  async ({
7528
7694
  platform: platform3,
@@ -7771,6 +7937,13 @@ function registerScreenshotTools(server2) {
7771
7937
  "Extra milliseconds to wait after page load before capturing. Useful for animations. Defaults to 2000."
7772
7938
  )
7773
7939
  },
7940
+ {
7941
+ title: "Capture App Page",
7942
+ readOnlyHint: true,
7943
+ destructiveHint: false,
7944
+ idempotentHint: false,
7945
+ openWorldHint: false
7946
+ },
7774
7947
  async ({ page: pageName, viewport, theme, selector, wait_ms }) => {
7775
7948
  const startedAt = Date.now();
7776
7949
  let rateLimitKey = "anonymous";
@@ -7891,6 +8064,13 @@ function registerScreenshotTools(server2) {
7891
8064
  ),
7892
8065
  wait_ms: z6.number().min(0).max(3e4).optional().describe("Extra milliseconds to wait after page load before capturing. Defaults to 1000.")
7893
8066
  },
8067
+ {
8068
+ title: "Capture Screenshot",
8069
+ readOnlyHint: true,
8070
+ destructiveHint: false,
8071
+ idempotentHint: false,
8072
+ openWorldHint: true
8073
+ },
7894
8074
  async ({ url, viewport, selector, output_path, wait_ms }) => {
7895
8075
  const startedAt = Date.now();
7896
8076
  let rateLimitKey = "anonymous";
@@ -8153,6 +8333,13 @@ function registerRemotionTools(server2) {
8153
8333
  "list_compositions",
8154
8334
  "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.",
8155
8335
  {},
8336
+ {
8337
+ title: "List Compositions",
8338
+ readOnlyHint: true,
8339
+ destructiveHint: false,
8340
+ idempotentHint: true,
8341
+ openWorldHint: false
8342
+ },
8156
8343
  async () => {
8157
8344
  const lines = [`${COMPOSITIONS.length} Remotion compositions available:`, ""];
8158
8345
  for (const comp of COMPOSITIONS) {
@@ -8181,6 +8368,13 @@ function registerRemotionTools(server2) {
8181
8368
  "JSON string of input props to pass to the composition. Each composition accepts different props. Omit for defaults."
8182
8369
  )
8183
8370
  },
8371
+ {
8372
+ title: "Render Demo Video",
8373
+ readOnlyHint: false,
8374
+ destructiveHint: false,
8375
+ idempotentHint: false,
8376
+ openWorldHint: false
8377
+ },
8184
8378
  async ({ composition_id, output_format, props }) => {
8185
8379
  const startedAt = Date.now();
8186
8380
  const userId = await getDefaultUserId();
@@ -8362,6 +8556,13 @@ function registerInsightsTools(server2) {
8362
8556
  limit: z8.number().min(1).max(50).optional().describe("Maximum number of insights to return. Defaults to 10."),
8363
8557
  response_format: z8.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
8364
8558
  },
8559
+ {
8560
+ title: "Get Performance Insights",
8561
+ readOnlyHint: true,
8562
+ destructiveHint: false,
8563
+ idempotentHint: true,
8564
+ openWorldHint: false
8565
+ },
8365
8566
  async ({ insight_type, days, limit, response_format }) => {
8366
8567
  const format = response_format ?? "text";
8367
8568
  const supabase = getSupabaseClient();
@@ -8489,6 +8690,13 @@ function registerInsightsTools(server2) {
8489
8690
  days: z8.number().min(1).max(90).optional().describe("Number of days to analyze. Defaults to 30. Max 90."),
8490
8691
  response_format: z8.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
8491
8692
  },
8693
+ {
8694
+ title: "Get Best Posting Times",
8695
+ readOnlyHint: true,
8696
+ destructiveHint: false,
8697
+ idempotentHint: true,
8698
+ openWorldHint: false
8699
+ },
8492
8700
  async ({ platform: platform3, days, response_format }) => {
8493
8701
  const format = response_format ?? "text";
8494
8702
  const supabase = getSupabaseClient();
@@ -8638,6 +8846,13 @@ function registerYouTubeAnalyticsTools(server2) {
8638
8846
  video_id: z9.string().optional().describe('YouTube video ID. Required when action is "video".'),
8639
8847
  max_results: z9.number().min(1).max(50).optional().describe('Max videos to return for "topVideos" action. Defaults to 10.')
8640
8848
  },
8849
+ {
8850
+ title: "Fetch YouTube Analytics",
8851
+ readOnlyHint: true,
8852
+ destructiveHint: false,
8853
+ idempotentHint: true,
8854
+ openWorldHint: true
8855
+ },
8641
8856
  async ({ action, start_date, end_date, video_id, max_results }) => {
8642
8857
  if (action === "video" && !video_id) {
8643
8858
  return {
@@ -8751,15 +8966,20 @@ function asEnvelope6(data) {
8751
8966
  function registerCommentsTools(server2) {
8752
8967
  server2.tool(
8753
8968
  "list_comments",
8754
- "List YouTube comments. Without a video_id, returns recent comments across all channel videos. With a video_id, returns comments for that specific video.",
8969
+ 'List YouTube comments \u2014 pass video_id (11-char string, e.g. "dQw4w9WgXcQ") for a specific video, or omit for recent comments across all channel videos. Returns comment text, author, like count, and reply count. Use page_token from previous response for pagination.',
8755
8970
  {
8756
- video_id: z10.string().optional().describe(
8757
- "YouTube video ID. If omitted, returns comments across all channel videos."
8758
- ),
8971
+ video_id: z10.string().optional().describe('YouTube video ID \u2014 the 11-character string from the URL (e.g. "dQw4w9WgXcQ" from youtube.com/watch?v=dQw4w9WgXcQ). Omit to get recent comments across all channel videos.'),
8759
8972
  max_results: z10.number().min(1).max(100).optional().describe("Maximum number of comments to return. Defaults to 50."),
8760
- page_token: z10.string().optional().describe("Pagination token from a previous list_comments call."),
8973
+ page_token: z10.string().optional().describe("Pagination cursor from previous list_comments response nextPageToken field. Omit for first page of results."),
8761
8974
  response_format: z10.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
8762
8975
  },
8976
+ {
8977
+ title: "List Comments",
8978
+ readOnlyHint: true,
8979
+ destructiveHint: false,
8980
+ idempotentHint: true,
8981
+ openWorldHint: true
8982
+ },
8763
8983
  async ({ video_id, max_results, page_token, response_format }) => {
8764
8984
  const format = response_format ?? "text";
8765
8985
  const { data, error } = await callEdgeFunction("youtube-comments", {
@@ -8822,7 +9042,7 @@ function registerCommentsTools(server2) {
8822
9042
  );
8823
9043
  server2.tool(
8824
9044
  "reply_to_comment",
8825
- "Reply to a YouTube comment. Requires the parent comment ID and reply text.",
9045
+ "Reply to a YouTube comment. Get the parent_id from list_comments results. Reply appears as the authenticated channel. Use for community engagement after checking list_comments for questions or feedback.",
8826
9046
  {
8827
9047
  parent_id: z10.string().describe(
8828
9048
  "The ID of the parent comment to reply to (from list_comments)."
@@ -8830,6 +9050,13 @@ function registerCommentsTools(server2) {
8830
9050
  text: z10.string().min(1).describe("The reply text."),
8831
9051
  response_format: z10.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
8832
9052
  },
9053
+ {
9054
+ title: "Reply to Comment",
9055
+ readOnlyHint: false,
9056
+ destructiveHint: false,
9057
+ idempotentHint: false,
9058
+ openWorldHint: true
9059
+ },
8833
9060
  async ({ parent_id, text, response_format }) => {
8834
9061
  const format = response_format ?? "text";
8835
9062
  const startedAt = Date.now();
@@ -8911,6 +9138,13 @@ function registerCommentsTools(server2) {
8911
9138
  text: z10.string().min(1).describe("The comment text."),
8912
9139
  response_format: z10.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
8913
9140
  },
9141
+ {
9142
+ title: "Post Comment",
9143
+ readOnlyHint: false,
9144
+ destructiveHint: false,
9145
+ idempotentHint: false,
9146
+ openWorldHint: true
9147
+ },
8914
9148
  async ({ video_id, text, response_format }) => {
8915
9149
  const format = response_format ?? "text";
8916
9150
  const startedAt = Date.now();
@@ -8989,6 +9223,13 @@ function registerCommentsTools(server2) {
8989
9223
  moderation_status: z10.enum(["published", "rejected"]).describe('"published" to approve, "rejected" to hide.'),
8990
9224
  response_format: z10.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
8991
9225
  },
9226
+ {
9227
+ title: "Moderate Comment",
9228
+ readOnlyHint: false,
9229
+ destructiveHint: false,
9230
+ idempotentHint: true,
9231
+ openWorldHint: true
9232
+ },
8992
9233
  async ({ comment_id, moderation_status, response_format }) => {
8993
9234
  const format = response_format ?? "text";
8994
9235
  const startedAt = Date.now();
@@ -9074,6 +9315,13 @@ function registerCommentsTools(server2) {
9074
9315
  comment_id: z10.string().describe("The comment ID to delete."),
9075
9316
  response_format: z10.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
9076
9317
  },
9318
+ {
9319
+ title: "Delete Comment",
9320
+ readOnlyHint: false,
9321
+ destructiveHint: true,
9322
+ idempotentHint: true,
9323
+ openWorldHint: true
9324
+ },
9077
9325
  async ({ comment_id, response_format }) => {
9078
9326
  const format = response_format ?? "text";
9079
9327
  const startedAt = Date.now();
@@ -9223,12 +9471,19 @@ function asEnvelope7(data) {
9223
9471
  function registerIdeationContextTools(server2) {
9224
9472
  server2.tool(
9225
9473
  "get_ideation_context",
9226
- "Get synthesized ideation context from performance insights. Returns the same prompt-injection context used by ideation generation.",
9474
+ "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.",
9227
9475
  {
9228
9476
  project_id: z11.string().uuid().optional().describe("Project ID to scope insights."),
9229
9477
  days: z11.number().min(1).max(90).optional().describe("Lookback window for insights. Defaults to 30 days."),
9230
9478
  response_format: z11.enum(["text", "json"]).optional().describe("Optional output format. Defaults to text.")
9231
9479
  },
9480
+ {
9481
+ title: "Get Ideation Context",
9482
+ readOnlyHint: true,
9483
+ destructiveHint: false,
9484
+ idempotentHint: true,
9485
+ openWorldHint: false
9486
+ },
9232
9487
  async ({ project_id, days, response_format }) => {
9233
9488
  const supabase = getSupabaseClient();
9234
9489
  const userId = await getDefaultUserId();
@@ -9355,10 +9610,17 @@ function asEnvelope8(data) {
9355
9610
  function registerCreditsTools(server2) {
9356
9611
  server2.tool(
9357
9612
  "get_credit_balance",
9358
- "Get current subscription credit balance and plan.",
9613
+ "Check remaining credits, monthly limit, spending cap, and plan tier. Call this before expensive operations \u2014 generate_video costs 15-80 credits, generate_image costs 2-10. Returns current balance, monthly allocation, and spending cap (2.5x allocation).",
9359
9614
  {
9360
9615
  response_format: z12.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
9361
9616
  },
9617
+ {
9618
+ title: "Get Credit Balance",
9619
+ readOnlyHint: true,
9620
+ destructiveHint: false,
9621
+ idempotentHint: true,
9622
+ openWorldHint: false
9623
+ },
9362
9624
  async ({ response_format }) => {
9363
9625
  const supabase = getSupabaseClient();
9364
9626
  const userId = await getDefaultUserId();
@@ -9408,10 +9670,17 @@ Monthly used: ${payload.monthlyUsed}` + (payload.monthlyLimit ? ` / ${payload.mo
9408
9670
  );
9409
9671
  server2.tool(
9410
9672
  "get_budget_status",
9411
- "Get current MCP run budget consumption for credits/assets.",
9673
+ "Check how much of the per-session budget has been consumed. Tracks credits spent and assets created in this MCP session against configured limits. Use to avoid hitting budget caps mid-workflow.",
9412
9674
  {
9413
9675
  response_format: z12.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
9414
9676
  },
9677
+ {
9678
+ title: "Get Budget Status",
9679
+ readOnlyHint: true,
9680
+ destructiveHint: false,
9681
+ idempotentHint: true,
9682
+ openWorldHint: false
9683
+ },
9415
9684
  async ({ response_format }) => {
9416
9685
  const budget = getCurrentBudgetStatus();
9417
9686
  const payload = {
@@ -9466,11 +9735,18 @@ function asEnvelope9(data) {
9466
9735
  function registerLoopSummaryTools(server2) {
9467
9736
  server2.tool(
9468
9737
  "get_loop_summary",
9469
- "Get a one-call dashboard summary of the feedback loop state (brand profile, recent content, and current insights).",
9738
+ "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.",
9470
9739
  {
9471
9740
  project_id: z13.string().uuid().optional().describe("Project ID. Defaults to active project context."),
9472
9741
  response_format: z13.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
9473
9742
  },
9743
+ {
9744
+ title: "Get Loop Summary",
9745
+ readOnlyHint: true,
9746
+ destructiveHint: false,
9747
+ idempotentHint: true,
9748
+ openWorldHint: false
9749
+ },
9474
9750
  async ({ project_id, response_format }) => {
9475
9751
  const supabase = getSupabaseClient();
9476
9752
  const userId = await getDefaultUserId();
@@ -9569,6 +9845,13 @@ function registerUsageTools(server2) {
9569
9845
  {
9570
9846
  response_format: z14.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
9571
9847
  },
9848
+ {
9849
+ title: "Get MCP Usage",
9850
+ readOnlyHint: true,
9851
+ destructiveHint: false,
9852
+ idempotentHint: true,
9853
+ openWorldHint: false
9854
+ },
9572
9855
  async ({ response_format }) => {
9573
9856
  const format = response_format ?? "text";
9574
9857
  const supabase = getSupabaseClient();
@@ -9654,11 +9937,18 @@ function asEnvelope11(data) {
9654
9937
  function registerAutopilotTools(server2) {
9655
9938
  server2.tool(
9656
9939
  "list_autopilot_configs",
9657
- "List all autopilot configurations for your account. Shows active schedules, associated recipes, credit budgets, and last run times.",
9940
+ "List autopilot configurations showing schedules, credit budgets, last run times, and active/inactive status. Use to check what is automated before creating new configs, or to find config_id for update_autopilot_config.",
9658
9941
  {
9659
9942
  active_only: z15.boolean().optional().describe("If true, only return active configs. Defaults to false (show all)."),
9660
9943
  response_format: z15.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
9661
9944
  },
9945
+ {
9946
+ title: "List Autopilot Configs",
9947
+ readOnlyHint: true,
9948
+ destructiveHint: false,
9949
+ idempotentHint: true,
9950
+ openWorldHint: false
9951
+ },
9662
9952
  async ({ active_only, response_format }) => {
9663
9953
  const format = response_format ?? "text";
9664
9954
  const supabase = getSupabaseClient();
@@ -9736,11 +10026,18 @@ ${"=".repeat(40)}
9736
10026
  {
9737
10027
  config_id: z15.string().uuid().describe("The autopilot config ID to update."),
9738
10028
  is_active: z15.boolean().optional().describe("Enable or disable this autopilot config."),
9739
- 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"]).'),
10029
+ 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"]).'),
9740
10030
  schedule_time: z15.string().optional().describe('Time to run in HH:MM format (24h, user timezone). E.g., "09:00".'),
9741
10031
  max_credits_per_run: z15.number().optional().describe("Maximum credits per execution."),
9742
10032
  max_credits_per_week: z15.number().optional().describe("Maximum credits per week.")
9743
10033
  },
10034
+ {
10035
+ title: "Update Autopilot Config",
10036
+ readOnlyHint: false,
10037
+ destructiveHint: false,
10038
+ idempotentHint: true,
10039
+ openWorldHint: false
10040
+ },
9744
10041
  async ({
9745
10042
  config_id,
9746
10043
  is_active,
@@ -9800,10 +10097,17 @@ Schedule: ${JSON.stringify(updated.schedule_config)}`
9800
10097
  );
9801
10098
  server2.tool(
9802
10099
  "get_autopilot_status",
9803
- "Get the current status of your autopilot system, including active configs, recent runs, and next scheduled execution.",
10100
+ "Get autopilot system overview: active config count, recent execution results, credits consumed, and next scheduled run time. Use as a dashboard check before modifying autopilot settings.",
9804
10101
  {
9805
10102
  response_format: z15.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
9806
10103
  },
10104
+ {
10105
+ title: "Get Autopilot Status",
10106
+ readOnlyHint: true,
10107
+ destructiveHint: false,
10108
+ idempotentHint: true,
10109
+ openWorldHint: false
10110
+ },
9807
10111
  async ({ response_format }) => {
9808
10112
  const format = response_format ?? "text";
9809
10113
  const supabase = getSupabaseClient();
@@ -9919,13 +10223,20 @@ ${content.suggested_hooks.map((h) => ` - ${h}`).join("\n")}`
9919
10223
  function registerExtractionTools(server2) {
9920
10224
  server2.tool(
9921
10225
  "extract_url_content",
9922
- "Extract content from a URL (YouTube video transcript, article text, product page). Routes to scrape-youtube for YouTube URLs or fetch-url-content for other URLs.",
10226
+ "Extract text content from any URL \u2014 YouTube video transcripts, article text, or product page features/benefits/USP. YouTube URLs auto-route to transcript extraction with optional comments. Use before generate_content to repurpose existing content, or before plan_content_week to base a content plan on a source URL.",
9923
10227
  {
9924
10228
  url: z16.string().url().describe("URL to extract content from"),
9925
10229
  extract_type: z16.enum(["auto", "transcript", "article", "product"]).default("auto").describe("Type of extraction"),
9926
10230
  include_comments: z16.boolean().default(false).describe("Include top comments (YouTube only)"),
9927
10231
  max_results: z16.number().min(1).max(100).default(10).describe("Max comments to include"),
9928
- response_format: z16.enum(["text", "json"]).default("text")
10232
+ response_format: z16.enum(["text", "json"]).default("text").describe("Response format. Defaults to text.")
10233
+ },
10234
+ {
10235
+ title: "Extract URL Content",
10236
+ readOnlyHint: true,
10237
+ destructiveHint: false,
10238
+ idempotentHint: true,
10239
+ openWorldHint: true
9929
10240
  },
9930
10241
  async ({
9931
10242
  url,
@@ -10119,9 +10430,9 @@ function asEnvelope13(data) {
10119
10430
  function registerQualityTools(server2) {
10120
10431
  server2.tool(
10121
10432
  "quality_check",
10122
- "Score a single post's content quality across 7 categories (Hook Strength, Message Clarity, Platform Fit, Brand Alignment, Novelty, CTA Strength, Safety/Claims). Returns pass/fail with per-category scores.",
10433
+ "Score post quality across 7 categories: Hook Strength, Message Clarity, Platform Fit, Brand Alignment, Novelty, CTA Strength, and Safety/Claims. Each scored 0-5, total 35. Default pass threshold is 26 (~75%). Run after generate_content and before schedule_post. Include hashtags in caption if they will be published \u2014 they affect Platform Fit and Safety scores.",
10123
10434
  {
10124
- caption: z17.string().describe("Post caption/body text"),
10435
+ caption: z17.string().describe("The post text to score. Include hashtags if they will be published \u2014 they affect Platform Fit and Safety/Claims scores."),
10125
10436
  title: z17.string().optional().describe("Post title (important for YouTube)"),
10126
10437
  platforms: z17.array(
10127
10438
  z17.enum([
@@ -10135,11 +10446,18 @@ function registerQualityTools(server2) {
10135
10446
  "bluesky"
10136
10447
  ])
10137
10448
  ).min(1).describe("Target platforms"),
10138
- threshold: z17.number().min(0).max(35).default(26).describe("Minimum total score to pass"),
10449
+ 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."),
10139
10450
  brand_keyword: z17.string().optional().describe("Brand keyword for alignment check"),
10140
- brand_avoid_patterns: z17.array(z17.string()).optional(),
10141
- custom_banned_terms: z17.array(z17.string()).optional(),
10142
- response_format: z17.enum(["text", "json"]).default("text")
10451
+ 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."),
10452
+ custom_banned_terms: z17.array(z17.string()).optional().describe("Additional banned words beyond the built-in safety list. Useful for industry-specific compliance terms."),
10453
+ response_format: z17.enum(["text", "json"]).default("text").describe("'text' for human-readable report, 'json' for structured scores suitable for pipeline automation.")
10454
+ },
10455
+ {
10456
+ title: "Quality Check",
10457
+ readOnlyHint: true,
10458
+ destructiveHint: false,
10459
+ idempotentHint: true,
10460
+ openWorldHint: false
10143
10461
  },
10144
10462
  async ({
10145
10463
  caption,
@@ -10206,20 +10524,27 @@ function registerQualityTools(server2) {
10206
10524
  );
10207
10525
  server2.tool(
10208
10526
  "quality_check_plan",
10209
- "Run quality checks on all posts in a content plan. Returns per-post scores and aggregate summary.",
10527
+ "Batch quality check all posts in a content plan. Returns per-post scores and aggregate pass/fail summary. Use after plan_content_week and before schedule_content_plan to catch low-quality posts before publishing.",
10210
10528
  {
10211
10529
  plan: z17.object({
10212
10530
  posts: z17.array(
10213
10531
  z17.object({
10214
- id: z17.string(),
10215
- caption: z17.string(),
10216
- title: z17.string().optional(),
10217
- platform: z17.string()
10532
+ id: z17.string().describe("Unique post identifier."),
10533
+ caption: z17.string().describe("Post caption/body text to quality-check."),
10534
+ title: z17.string().optional().describe("Post title (important for YouTube)."),
10535
+ platform: z17.string().describe("Target platform (e.g. instagram, youtube).")
10218
10536
  })
10219
10537
  )
10220
- }).passthrough().describe("Content plan with posts array"),
10221
- threshold: z17.number().min(0).max(35).default(26).describe("Minimum total score to pass"),
10222
- response_format: z17.enum(["text", "json"]).default("text")
10538
+ }).passthrough().describe("Content plan with posts array."),
10539
+ 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."),
10540
+ response_format: z17.enum(["text", "json"]).default("text").describe("Response format. Defaults to text.")
10541
+ },
10542
+ {
10543
+ title: "Quality Check Plan",
10544
+ readOnlyHint: true,
10545
+ destructiveHint: false,
10546
+ idempotentHint: true,
10547
+ openWorldHint: false
10223
10548
  },
10224
10549
  async ({ plan, threshold, response_format }) => {
10225
10550
  const startedAt = Date.now();
@@ -10401,7 +10726,7 @@ function formatPlanAsText(plan) {
10401
10726
  function registerPlanningTools(server2) {
10402
10727
  server2.tool(
10403
10728
  "plan_content_week",
10404
- "Generate a full week's content plan with platform-specific drafts. Takes a topic or source URL, loads brand context and performance insights, and returns structured posts with hooks, angles, captions, and suggested schedule times.",
10729
+ "Generate a full content plan with platform-specific drafts, hooks, angles, and optimal schedule times. Pass a topic or source_url \u2014 brand context and performance insights auto-load via project_id. Output feeds directly into quality_check_plan then schedule_content_plan. Costs ~5-15 credits depending on post count.",
10405
10730
  {
10406
10731
  topic: z18.string().describe("Main topic or content theme"),
10407
10732
  source_url: z18.string().optional().describe("URL to extract content from (YouTube, article)"),
@@ -10422,7 +10747,14 @@ function registerPlanningTools(server2) {
10422
10747
  start_date: z18.string().optional().describe("ISO date, defaults to tomorrow"),
10423
10748
  brand_voice: z18.string().optional().describe("Override brand voice description"),
10424
10749
  project_id: z18.string().optional().describe("Project ID for brand/insights context"),
10425
- response_format: z18.enum(["text", "json"]).default("json")
10750
+ response_format: z18.enum(["text", "json"]).default("json").describe("Response format. Defaults to json.")
10751
+ },
10752
+ {
10753
+ title: "Plan Content Week",
10754
+ readOnlyHint: false,
10755
+ destructiveHint: false,
10756
+ idempotentHint: false,
10757
+ openWorldHint: true
10426
10758
  },
10427
10759
  async ({
10428
10760
  topic,
@@ -10737,15 +11069,22 @@ ${rawText.slice(0, 1e3)}`
10737
11069
  );
10738
11070
  server2.tool(
10739
11071
  "save_content_plan",
10740
- "Persist a content plan payload for later review, approvals, and scheduling.",
11072
+ "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.",
10741
11073
  {
10742
11074
  plan: z18.object({
10743
- topic: z18.string(),
10744
- posts: z18.array(z18.record(z18.string(), z18.unknown()))
10745
- }).passthrough(),
10746
- project_id: z18.string().uuid().optional(),
10747
- status: z18.enum(["draft", "in_review", "approved", "scheduled", "completed"]).default("draft"),
10748
- response_format: z18.enum(["text", "json"]).default("json")
11075
+ topic: z18.string().describe("Content plan topic or theme."),
11076
+ posts: z18.array(z18.record(z18.string(), z18.unknown())).describe("Array of post objects to save.")
11077
+ }).passthrough().describe("Content plan object with topic and posts array."),
11078
+ project_id: z18.string().uuid().optional().describe("Project ID. Defaults to active project context."),
11079
+ status: z18.enum(["draft", "in_review", "approved", "scheduled", "completed"]).default("draft").describe("Initial plan status. Defaults to draft."),
11080
+ response_format: z18.enum(["text", "json"]).default("json").describe("Response format. Defaults to json.")
11081
+ },
11082
+ {
11083
+ title: "Save Content Plan",
11084
+ readOnlyHint: false,
11085
+ destructiveHint: false,
11086
+ idempotentHint: false,
11087
+ openWorldHint: false
10749
11088
  },
10750
11089
  async ({ plan, project_id, status, response_format }) => {
10751
11090
  const startedAt = Date.now();
@@ -10843,10 +11182,17 @@ ${rawText.slice(0, 1e3)}`
10843
11182
  );
10844
11183
  server2.tool(
10845
11184
  "get_content_plan",
10846
- "Retrieve a persisted content plan by ID.",
11185
+ "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.",
10847
11186
  {
10848
11187
  plan_id: z18.string().uuid().describe("Persisted content plan ID"),
10849
- response_format: z18.enum(["text", "json"]).default("json")
11188
+ response_format: z18.enum(["text", "json"]).default("json").describe("Response format. Defaults to json.")
11189
+ },
11190
+ {
11191
+ title: "Get Content Plan",
11192
+ readOnlyHint: true,
11193
+ destructiveHint: false,
11194
+ idempotentHint: true,
11195
+ openWorldHint: false
10850
11196
  },
10851
11197
  async ({ plan_id, response_format }) => {
10852
11198
  const supabase = getSupabaseClient();
@@ -10911,25 +11257,32 @@ ${rawText.slice(0, 1e3)}`
10911
11257
  );
10912
11258
  server2.tool(
10913
11259
  "update_content_plan",
10914
- "Update individual posts in a persisted content plan.",
11260
+ "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.",
10915
11261
  {
10916
- plan_id: z18.string().uuid(),
11262
+ plan_id: z18.string().uuid().describe("Content plan ID to update."),
10917
11263
  post_updates: z18.array(
10918
11264
  z18.object({
10919
- post_id: z18.string(),
10920
- caption: z18.string().optional(),
10921
- title: z18.string().optional(),
10922
- hashtags: z18.array(z18.string()).optional(),
10923
- hook: z18.string().optional(),
10924
- angle: z18.string().optional(),
10925
- visual_direction: z18.string().optional(),
10926
- media_url: z18.string().optional(),
10927
- schedule_at: z18.string().optional(),
10928
- platform: z18.string().optional(),
10929
- status: z18.enum(["approved", "rejected", "needs_edit"]).optional()
11265
+ post_id: z18.string().describe("ID of the post to update within this plan."),
11266
+ caption: z18.string().optional().describe("Revised caption/body text."),
11267
+ title: z18.string().optional().describe("Revised post title."),
11268
+ hashtags: z18.array(z18.string()).optional().describe("Revised hashtags array."),
11269
+ hook: z18.string().optional().describe("Revised attention-grabbing opening line."),
11270
+ angle: z18.string().optional().describe("Revised content angle or perspective."),
11271
+ visual_direction: z18.string().optional().describe("Revised visual/media direction notes."),
11272
+ media_url: z18.string().optional().describe("Revised media URL (public or R2 signed URL)."),
11273
+ schedule_at: z18.string().optional().describe("Revised ISO 8601 UTC publish datetime."),
11274
+ platform: z18.string().optional().describe("Revised target platform."),
11275
+ status: z18.enum(["approved", "rejected", "needs_edit"]).optional().describe("Review status for this post.")
10930
11276
  })
10931
- ).min(1),
10932
- response_format: z18.enum(["text", "json"]).default("json")
11277
+ ).min(1).describe("Array of post-level updates to apply."),
11278
+ response_format: z18.enum(["text", "json"]).default("json").describe("Response format. Defaults to json.")
11279
+ },
11280
+ {
11281
+ title: "Update Content Plan",
11282
+ readOnlyHint: false,
11283
+ destructiveHint: false,
11284
+ idempotentHint: true,
11285
+ openWorldHint: false
10933
11286
  },
10934
11287
  async ({ plan_id, post_updates, response_format }) => {
10935
11288
  const supabase = getSupabaseClient();
@@ -11030,10 +11383,17 @@ ${rawText.slice(0, 1e3)}`
11030
11383
  );
11031
11384
  server2.tool(
11032
11385
  "submit_content_plan_for_approval",
11033
- "Create pending approval items for each post in a plan and mark plan status as in_review.",
11386
+ "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.",
11387
+ {
11388
+ plan_id: z18.string().uuid().describe("Content plan ID to submit for review."),
11389
+ response_format: z18.enum(["text", "json"]).default("json").describe("Response format. Defaults to json.")
11390
+ },
11034
11391
  {
11035
- plan_id: z18.string().uuid(),
11036
- response_format: z18.enum(["text", "json"]).default("json")
11392
+ title: "Submit Plan for Approval",
11393
+ readOnlyHint: false,
11394
+ destructiveHint: false,
11395
+ idempotentHint: true,
11396
+ openWorldHint: false
11037
11397
  },
11038
11398
  async ({ plan_id, response_format }) => {
11039
11399
  const supabase = getSupabaseClient();
@@ -11158,21 +11518,28 @@ async function assertProjectAccess(supabase, userId, projectId) {
11158
11518
  function registerPlanApprovalTools(server2) {
11159
11519
  server2.tool(
11160
11520
  "create_plan_approvals",
11161
- "Create pending approval rows for each post in a content plan.",
11521
+ "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.",
11162
11522
  {
11163
11523
  plan_id: z19.string().uuid().describe("Content plan ID"),
11164
11524
  posts: z19.array(
11165
11525
  z19.object({
11166
- id: z19.string(),
11167
- platform: z19.string().optional(),
11168
- caption: z19.string().optional(),
11169
- title: z19.string().optional(),
11170
- media_url: z19.string().optional(),
11171
- schedule_at: z19.string().optional()
11526
+ id: z19.string().describe("Unique post identifier from the content plan."),
11527
+ platform: z19.string().optional().describe("Target platform (e.g. instagram, youtube)."),
11528
+ caption: z19.string().optional().describe("Post caption/body text."),
11529
+ title: z19.string().optional().describe("Post title, used by YouTube and LinkedIn articles."),
11530
+ media_url: z19.string().optional().describe("Public or R2 signed URL for the post media."),
11531
+ schedule_at: z19.string().optional().describe("ISO 8601 UTC datetime to publish (e.g. 2026-03-20T14:00:00Z).")
11172
11532
  }).passthrough()
11173
11533
  ).min(1).describe("Posts to create approval entries for."),
11174
11534
  project_id: z19.string().uuid().optional().describe("Project ID. Defaults to active project context."),
11175
- response_format: z19.enum(["text", "json"]).optional()
11535
+ response_format: z19.enum(["text", "json"]).optional().describe("Response format. Defaults to text.")
11536
+ },
11537
+ {
11538
+ title: "Create Plan Approvals",
11539
+ readOnlyHint: false,
11540
+ destructiveHint: false,
11541
+ idempotentHint: false,
11542
+ openWorldHint: false
11176
11543
  },
11177
11544
  async ({ plan_id, posts, project_id, response_format }) => {
11178
11545
  const supabase = getSupabaseClient();
@@ -11252,8 +11619,15 @@ function registerPlanApprovalTools(server2) {
11252
11619
  "List MCP-native approval items for a specific content plan.",
11253
11620
  {
11254
11621
  plan_id: z19.string().uuid().describe("Content plan ID"),
11255
- status: z19.enum(["pending", "approved", "rejected", "edited"]).optional(),
11256
- response_format: z19.enum(["text", "json"]).optional()
11622
+ status: z19.enum(["pending", "approved", "rejected", "edited"]).optional().describe("Filter approvals by status. Omit to return all statuses."),
11623
+ response_format: z19.enum(["text", "json"]).optional().describe("Response format. Defaults to text.")
11624
+ },
11625
+ {
11626
+ title: "List Plan Approvals",
11627
+ readOnlyHint: true,
11628
+ destructiveHint: false,
11629
+ idempotentHint: true,
11630
+ openWorldHint: false
11257
11631
  },
11258
11632
  async ({ plan_id, status, response_format }) => {
11259
11633
  const supabase = getSupabaseClient();
@@ -11320,10 +11694,19 @@ function registerPlanApprovalTools(server2) {
11320
11694
  "Approve, reject, or edit a pending plan approval item.",
11321
11695
  {
11322
11696
  approval_id: z19.string().uuid().describe("Approval item ID"),
11323
- decision: z19.enum(["approved", "rejected", "edited"]),
11324
- edited_post: z19.record(z19.string(), z19.unknown()).optional(),
11325
- reason: z19.string().max(1e3).optional(),
11326
- response_format: z19.enum(["text", "json"]).optional()
11697
+ decision: z19.enum(["approved", "rejected", "edited"]).describe("Approval decision for this post."),
11698
+ edited_post: z19.record(z19.string(), z19.unknown()).optional().describe(
11699
+ 'Revised post fields when decision is "edited" (e.g. {caption: "...", hashtags: [...]}).'
11700
+ ),
11701
+ reason: z19.string().max(1e3).optional().describe("Optional reason for the decision, visible to the plan author."),
11702
+ response_format: z19.enum(["text", "json"]).optional().describe("Response format. Defaults to text.")
11703
+ },
11704
+ {
11705
+ title: "Respond to Plan Approval",
11706
+ readOnlyHint: false,
11707
+ destructiveHint: false,
11708
+ idempotentHint: true,
11709
+ openWorldHint: false
11327
11710
  },
11328
11711
  async ({ approval_id, decision, edited_post, reason, response_format }) => {
11329
11712
  const supabase = getSupabaseClient();
@@ -11402,7 +11785,7 @@ import { z as z20 } from "zod";
11402
11785
  function registerDiscoveryTools(server2) {
11403
11786
  server2.tool(
11404
11787
  "search_tools",
11405
- 'Search and discover available MCP tools. Use detail level to control token usage: "name" (~50 tokens), "summary" (~500 tokens), "full" (complete schemas).',
11788
+ 'Search available tools by name, description, module, or scope. Use "name" detail (~50 tokens) for quick lookup, "summary" (~500 tokens) for descriptions, "full" for complete input schemas. Start here if unsure which tool to call \u2014 filter by module (e.g. "planning", "content", "analytics") to narrow results.',
11406
11789
  {
11407
11790
  query: z20.string().optional().describe("Search query to filter tools by name or description"),
11408
11791
  module: z20.string().optional().describe('Filter by module name (e.g. "planning", "content", "analytics")'),
@@ -11411,6 +11794,13 @@ function registerDiscoveryTools(server2) {
11411
11794
  'Detail level: "name" for just tool names, "summary" for names + descriptions, "full" for complete info including scope and module'
11412
11795
  )
11413
11796
  },
11797
+ {
11798
+ title: "Search Tools",
11799
+ readOnlyHint: true,
11800
+ destructiveHint: false,
11801
+ idempotentHint: true,
11802
+ openWorldHint: false
11803
+ },
11414
11804
  async ({ query, module, scope, detail }) => {
11415
11805
  let results = [...TOOL_CATALOG];
11416
11806
  if (query) {