@socialneuron/mcp-server 1.7.18 → 1.8.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/sn.js CHANGED
@@ -19,7 +19,7 @@ var MCP_VERSION;
19
19
  var init_version = __esm({
20
20
  "src/lib/version.ts"() {
21
21
  "use strict";
22
- MCP_VERSION = "1.7.18";
22
+ MCP_VERSION = "1.8.1";
23
23
  }
24
24
  });
25
25
 
@@ -34,6 +34,20 @@ function getRequestScopes() {
34
34
  function getRequestToken() {
35
35
  return requestContext.getStore()?.token ?? null;
36
36
  }
37
+ function getRequestSurface() {
38
+ return requestContext.getStore()?.surface ?? null;
39
+ }
40
+ function getRequestProjectId() {
41
+ return requestContext.getStore()?.projectId ?? null;
42
+ }
43
+ function resolveSurface() {
44
+ const ctx = getRequestSurface();
45
+ if (ctx) return ctx;
46
+ const transport = process.env.MCP_TRANSPORT;
47
+ if (transport === "stdio") return "mcp-stdio";
48
+ if (transport === "http") return "mcp-http";
49
+ return "cli";
50
+ }
37
51
  var requestContext;
38
52
  var init_request_context = __esm({
39
53
  "src/lib/request-context.ts"() {
@@ -348,7 +362,10 @@ async function captureToolEvent(args) {
348
362
  properties: {
349
363
  tool_name: args.toolName,
350
364
  duration_ms: args.durationMs,
351
- ...args.details
365
+ ...args.details,
366
+ // Which surface invoked the tool: mcp-stdio | mcp-http | rest | cli.
367
+ // Authoritative over any details.surface (there are no such callers today).
368
+ surface: resolveSurface()
352
369
  }
353
370
  });
354
371
  }
@@ -363,6 +380,7 @@ var init_posthog = __esm({
363
380
  "src/lib/posthog.ts"() {
364
381
  "use strict";
365
382
  init_supabase();
383
+ init_request_context();
366
384
  POSTHOG_SALT = "socialneuron-mcp-ph-v1";
367
385
  client = null;
368
386
  }
@@ -377,6 +395,7 @@ __export(supabase_exports, {
377
395
  getAuthenticatedApiKey: () => getAuthenticatedApiKey,
378
396
  getAuthenticatedEmail: () => getAuthenticatedEmail,
379
397
  getAuthenticatedExpiresAt: () => getAuthenticatedExpiresAt,
398
+ getAuthenticatedProjectId: () => getAuthenticatedProjectId,
380
399
  getAuthenticatedScopes: () => getAuthenticatedScopes,
381
400
  getDefaultProjectId: () => getDefaultProjectId,
382
401
  getDefaultUserId: () => getDefaultUserId,
@@ -428,6 +447,9 @@ async function getDefaultUserId() {
428
447
  throw new Error("No user ID available. Set SOCIALNEURON_USER_ID or authenticate via API key.");
429
448
  }
430
449
  async function getDefaultProjectId() {
450
+ const requestProjectId = getRequestProjectId();
451
+ if (requestProjectId) return requestProjectId;
452
+ if (authenticatedProjectId) return authenticatedProjectId;
431
453
  const userId = await getDefaultUserId().catch(() => null);
432
454
  if (userId) {
433
455
  const cached = projectIdCache.get(userId);
@@ -469,6 +491,7 @@ async function initializeAuth() {
469
491
  authenticatedScopes = result.scopes && result.scopes.length > 0 ? result.scopes : ["mcp:read"];
470
492
  authenticatedEmail = result.email || null;
471
493
  authenticatedExpiresAt = result.expiresAt || null;
494
+ authenticatedProjectId = result.projectId || null;
472
495
  if (!_quietAuth) {
473
496
  console.error(
474
497
  "[MCP] Authenticated via API key (prefix: " + apiKey.substring(0, 6) + "..." + apiKey.slice(-4) + ")"
@@ -525,6 +548,9 @@ function getAuthMode() {
525
548
  function getAuthenticatedApiKey() {
526
549
  return authenticatedApiKey;
527
550
  }
551
+ function getAuthenticatedProjectId() {
552
+ return authenticatedProjectId;
553
+ }
528
554
  function isTelemetryDisabled() {
529
555
  return process.env.DO_NOT_TRACK === "1" || process.env.DO_NOT_TRACK === "true" || process.env.SOCIALNEURON_NO_TELEMETRY === "1";
530
556
  }
@@ -554,7 +580,7 @@ async function logMcpToolInvocation(args) {
554
580
  Promise.resolve().then(() => (init_posthog(), posthog_exports)).then(({ captureToolEvent: captureToolEvent2 }) => captureToolEvent2(args)).catch(() => {
555
581
  });
556
582
  }
557
- var SUPABASE_URL, SUPABASE_SERVICE_KEY, client2, _authMode, authenticatedUserId, authenticatedScopes, authenticatedEmail, authenticatedExpiresAt, authenticatedApiKey, MCP_RUN_ID, CLOUD_SUPABASE_URL, CLOUD_SUPABASE_ANON_KEY, projectIdCache;
583
+ var SUPABASE_URL, SUPABASE_SERVICE_KEY, client2, _authMode, authenticatedUserId, authenticatedScopes, authenticatedEmail, authenticatedExpiresAt, authenticatedApiKey, authenticatedProjectId, MCP_RUN_ID, CLOUD_SUPABASE_URL, CLOUD_SUPABASE_ANON_KEY, projectIdCache;
558
584
  var init_supabase = __esm({
559
585
  "src/lib/supabase.ts"() {
560
586
  "use strict";
@@ -568,6 +594,7 @@ var init_supabase = __esm({
568
594
  authenticatedEmail = null;
569
595
  authenticatedExpiresAt = null;
570
596
  authenticatedApiKey = null;
597
+ authenticatedProjectId = null;
571
598
  MCP_RUN_ID = randomUUID();
572
599
  CLOUD_SUPABASE_URL = "https://rhukkjscgzauutioyeei.supabase.co";
573
600
  CLOUD_SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InJodWtranNjZ3phdXV0aW95ZWVpIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjQ4NjM4ODYsImV4cCI6MjA4MDQzOTg4Nn0.JVtrviGvN0HaSh0JFS5KNl5FAB5ffG5Y1IMZsQFUrNQ";
@@ -2618,13 +2645,15 @@ var init_tool_catalog = __esm({
2618
2645
  name: "get_loop_pulse",
2619
2646
  description: "Read dynamic loop-health KPIs for the growth loop over the last 7 days (reflection/decision coverage, visual gate pass rate, learning-update application rate, per-platform uptake, autopilot lag) \u2014 each with an ok/warn/bad status. Use to decide whether the loop is closing or where it is stuck.",
2620
2647
  module: "loop",
2621
- scope: "mcp:read"
2648
+ scope: "mcp:read",
2649
+ internal: true
2622
2650
  },
2623
2651
  {
2624
2652
  name: "get_bandit_state",
2625
2653
  description: "Read the current content learning state for a project \u2014 top-K arms per (arm_type, platform) with expected performance and uncertainty. Use to reason about which hook family / format / timing slot currently performs best per platform.",
2626
2654
  module: "loop",
2627
- scope: "mcp:read"
2655
+ scope: "mcp:read",
2656
+ internal: true
2628
2657
  }
2629
2658
  ];
2630
2659
  }
@@ -2980,7 +3009,9 @@ var init_tool_annotations = __esm({
2980
3009
  },
2981
3010
  "mcp:write": {
2982
3011
  readOnlyHint: false,
2983
- destructiveHint: true,
3012
+ // Most write tools create additive drafts/assets. Per MCP semantics,
3013
+ // destructiveHint is reserved for overwrite/delete/irreversible behavior.
3014
+ destructiveHint: false,
2984
3015
  idempotentHint: false,
2985
3016
  openWorldHint: false
2986
3017
  },
@@ -2998,21 +3029,30 @@ var init_tool_annotations = __esm({
2998
3029
  },
2999
3030
  "mcp:comments": {
3000
3031
  readOnlyHint: false,
3001
- destructiveHint: true,
3032
+ destructiveHint: false,
3002
3033
  idempotentHint: false,
3003
3034
  openWorldHint: true
3004
3035
  },
3005
3036
  "mcp:autopilot": {
3006
3037
  readOnlyHint: false,
3007
- destructiveHint: true,
3038
+ destructiveHint: false,
3008
3039
  idempotentHint: false,
3009
3040
  openWorldHint: false
3010
3041
  }
3011
3042
  };
3012
3043
  OVERRIDES = {
3013
- // Destructive tools
3044
+ // Destructive or overwrite tools
3014
3045
  delete_comment: { destructiveHint: true },
3015
3046
  moderate_comment: { destructiveHint: true },
3047
+ save_brand_profile: { destructiveHint: true, idempotentHint: true },
3048
+ update_platform_voice: { destructiveHint: true, idempotentHint: true },
3049
+ update_content_plan: { destructiveHint: true, idempotentHint: true },
3050
+ respond_plan_approval: { destructiveHint: true, idempotentHint: true },
3051
+ update_autopilot_config: { destructiveHint: true, idempotentHint: true },
3052
+ run_content_pipeline: { destructiveHint: true, openWorldHint: true },
3053
+ auto_approve_plan: { destructiveHint: true, idempotentHint: true },
3054
+ execute_recipe: { destructiveHint: true, openWorldHint: true },
3055
+ run_skill: { destructiveHint: true },
3016
3056
  // Read-only tools in non-read scopes (must also clear destructiveHint from scope default)
3017
3057
  list_comments: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
3018
3058
  list_autopilot_configs: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
@@ -3023,14 +3063,12 @@ var init_tool_annotations = __esm({
3023
3063
  // Analytics tool that triggers side effects (data refresh)
3024
3064
  refresh_platform_analytics: { readOnlyHint: false, idempotentHint: true },
3025
3065
  // Write tools that are idempotent
3026
- save_brand_profile: { idempotentHint: true },
3027
- update_platform_voice: { idempotentHint: true },
3028
- update_autopilot_config: { idempotentHint: true },
3029
- update_content_plan: { idempotentHint: true },
3030
- respond_plan_approval: { idempotentHint: true },
3031
3066
  // Distribution is open-world (publishes to external platforms)
3032
3067
  schedule_post: { openWorldHint: true },
3033
3068
  schedule_content_plan: { openWorldHint: true },
3069
+ // This only creates a short-lived OAuth deep link; it does not connect the
3070
+ // external account until the user completes the browser flow.
3071
+ start_platform_connection: { destructiveHint: false },
3034
3072
  // Extraction reads external URLs
3035
3073
  extract_url_content: { openWorldHint: true },
3036
3074
  extract_brand: { openWorldHint: true },
@@ -3038,8 +3076,6 @@ var init_tool_annotations = __esm({
3038
3076
  check_pipeline_readiness: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
3039
3077
  get_pipeline_status: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
3040
3078
  // Pipeline: orchestration tools (non-idempotent, may schedule externally)
3041
- run_content_pipeline: { openWorldHint: true },
3042
- auto_approve_plan: { idempotentHint: true },
3043
3079
  // Suggest: read-only
3044
3080
  suggest_next_content: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
3045
3081
  // Digest/Anomalies: read-only analytics
@@ -3090,9 +3126,89 @@ function toolError(code, message, opts = {}) {
3090
3126
  ...opts.meta ? { _meta: opts.meta } : {}
3091
3127
  };
3092
3128
  }
3129
+ function classifyToolError(result) {
3130
+ const structured = result.structuredContent?.error?.error_type;
3131
+ if (structured && KNOWN_ERROR_TYPES.has(structured)) return structured;
3132
+ const text = result.content?.find((c) => c.type === "text")?.text ?? "";
3133
+ try {
3134
+ const parsed = JSON.parse(text);
3135
+ if (parsed.error_type && KNOWN_ERROR_TYPES.has(parsed.error_type)) {
3136
+ return parsed.error_type;
3137
+ }
3138
+ } catch {
3139
+ }
3140
+ if (/-32602|Input validation error|Invalid arguments/i.test(text)) {
3141
+ return "validation_error";
3142
+ }
3143
+ return "server_error";
3144
+ }
3145
+ var KNOWN_ERROR_TYPES;
3093
3146
  var init_tool_error = __esm({
3094
3147
  "src/lib/tool-error.ts"() {
3095
3148
  "use strict";
3149
+ KNOWN_ERROR_TYPES = /* @__PURE__ */ new Set([
3150
+ "policy_block",
3151
+ "validation_error",
3152
+ "permission_denied",
3153
+ "billing_error",
3154
+ "rate_limited",
3155
+ "not_found",
3156
+ "upstream_error",
3157
+ "server_error"
3158
+ ]);
3159
+ }
3160
+ });
3161
+
3162
+ // src/lib/tool-profile.ts
3163
+ function isToolAllowedByProfile(toolName, profile) {
3164
+ return profile === "full" || !ANTHROPIC_DIRECTORY_EXCLUDED_TOOLS.has(toolName);
3165
+ }
3166
+ function applyToolProfile(server, profile) {
3167
+ if (profile === "full") return;
3168
+ const currentTool = server.tool.bind(server);
3169
+ const currentRegisterTool = server.registerTool?.bind(server);
3170
+ server.tool = function profiledTool(...args) {
3171
+ const name = String(args[0] ?? "");
3172
+ if (!isToolAllowedByProfile(name, profile)) return void 0;
3173
+ return currentTool(...args);
3174
+ };
3175
+ if (currentRegisterTool) {
3176
+ server.registerTool = function profiledRegisterTool(...args) {
3177
+ const name = String(args[0] ?? "");
3178
+ if (!isToolAllowedByProfile(name, profile)) return void 0;
3179
+ return currentRegisterTool(...args);
3180
+ };
3181
+ }
3182
+ }
3183
+ var ANTHROPIC_DIRECTORY_EXCLUDED_TOOLS;
3184
+ var init_tool_profile = __esm({
3185
+ "src/lib/tool-profile.ts"() {
3186
+ "use strict";
3187
+ init_tool_catalog();
3188
+ ANTHROPIC_DIRECTORY_EXCLUDED_TOOLS = /* @__PURE__ */ new Set([
3189
+ "generate_video",
3190
+ "generate_image",
3191
+ "check_status",
3192
+ "create_storyboard",
3193
+ "generate_voiceover",
3194
+ "generate_carousel",
3195
+ "create_carousel",
3196
+ "render_demo_video",
3197
+ "list_compositions",
3198
+ "render_template_video",
3199
+ "check_pipeline_readiness",
3200
+ "run_content_pipeline",
3201
+ "get_pipeline_status",
3202
+ "auto_approve_plan",
3203
+ "list_recipes",
3204
+ "get_recipe_details",
3205
+ "execute_recipe",
3206
+ "get_recipe_run_status",
3207
+ "list_hyperframes_blocks",
3208
+ "render_hyperframes",
3209
+ "list_skills",
3210
+ "run_skill"
3211
+ ]);
3096
3212
  }
3097
3213
  });
3098
3214
 
@@ -3759,6 +3875,52 @@ var init_ideation = __esm({
3759
3875
  }
3760
3876
  });
3761
3877
 
3878
+ // src/lib/checkStatusShape.ts
3879
+ function buildCheckStatusPayload(job, liveStatus) {
3880
+ const status = liveStatus?.status ?? job.status;
3881
+ const progress = liveStatus?.progress ?? null;
3882
+ const resultUrl = liveStatus?.resultUrl ?? job.result_url ?? null;
3883
+ const r2Key = resultUrl && !resultUrl.startsWith("http") ? resultUrl : null;
3884
+ const errorMessage = liveStatus?.error ?? job.error_message ?? null;
3885
+ const allUrls = job.result_metadata?.all_urls ?? null;
3886
+ const modelRequested = job.result_metadata?.model_requested ?? null;
3887
+ const modelDelivered = job.result_metadata?.model_delivered ?? null;
3888
+ const fallbackReason = job.result_metadata?.fallback_reason ?? null;
3889
+ return {
3890
+ job_id: job.id,
3891
+ job_type: job.job_type,
3892
+ model: job.model,
3893
+ status,
3894
+ progress,
3895
+ result_url: resultUrl,
3896
+ r2_key: r2Key,
3897
+ all_urls: allUrls,
3898
+ error: errorMessage,
3899
+ credits_cost: job.credits_cost,
3900
+ created_at: job.created_at,
3901
+ completed_at: job.completed_at,
3902
+ model_requested: modelRequested,
3903
+ model_delivered: modelDelivered,
3904
+ fallback_reason: fallbackReason,
3905
+ id: job.id,
3906
+ jobId: job.id,
3907
+ jobType: job.job_type,
3908
+ resultUrl,
3909
+ credits: job.credits_cost,
3910
+ error_message: errorMessage,
3911
+ createdAt: job.created_at,
3912
+ completedAt: job.completed_at,
3913
+ modelRequested,
3914
+ modelDelivered,
3915
+ fallbackReason
3916
+ };
3917
+ }
3918
+ var init_checkStatusShape = __esm({
3919
+ "src/lib/checkStatusShape.ts"() {
3920
+ "use strict";
3921
+ }
3922
+ });
3923
+
3762
3924
  // src/lib/budget.ts
3763
3925
  function getCreditsUsed() {
3764
3926
  const ctx = requestContext.getStore();
@@ -3848,34 +4010,32 @@ function asEnvelope2(data) {
3848
4010
  function registerContentTools(server) {
3849
4011
  server.tool(
3850
4012
  "generate_video",
3851
- "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.",
4013
+ "Start an async AI video generation job \u2014 returns a job_id immediately. Poll with check_status every 10-30s until complete. Base credit costs (reference config; dynamic models scale with duration/audio/resolution): seedance-2-fast 264 (8s, best quality/credit) \xB7 kling-3 100 (5s no-audio) \xB7 grok-imagine 30 (6s, cheapest) \xB7 veo3-fast 65 (8s) \xB7 kling-3-pro 135 (5s no-audio) \xB7 seedance-2 328 (8s 720p) \xB7 veo3-quality 1000 (8s) \xB7 wan-2.6 105 (5s) \xB7 gemini-omni-video 126 (multi-reference composites) \xB7 hailuo-02-standard 180 / seedance-1.5-pro 150 / kling 170 (legacy/budget fallbacks). Costs are estimated pre-check and reconciled post-response from the actual charge. audio adds ~1.5x on kling-3/kling-3-pro and ~2.65x on kling (enable_audio defaults to FALSE). Check get_credit_balance first for expensive generations.",
3852
4014
  {
3853
4015
  prompt: z2.string().max(2500).describe(
3854
4016
  '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.'
3855
4017
  ),
3856
- model: z2.enum([
3857
- "veo3-fast",
3858
- "veo3-quality",
3859
- "runway-aleph",
3860
- "sora2",
3861
- "sora2-pro",
3862
- "kling",
3863
- "kling-3",
3864
- "kling-3-pro"
3865
- ]).describe(
3866
- "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)."
4018
+ model: z2.enum(VIDEO_MODEL_ENUM).describe(
4019
+ "Video model, quality ladder best->worst: seedance-2-fast (264cr/8s, S-tier, native audio, top quality/credit) > kling-3 (100cr/5s no-audio, compound prompts, up to 10s) > grok-imagine (30cr/6s, cheapest real model, great image-to-video) > veo3-fast (65cr/8s, Veo 3.1 Fast, native audio) > kling-3-pro (135cr/5s no-audio, up to 15s) > seedance-2 (328cr/8s 720p, premium cinematic, native audio) > veo3-quality (1000cr/8s, Veo 3.1 Quality, hero shots) > wan-2.6 (105cr/5s, fallback) > gemini-omni-video (126cr \u2014 pick this ONLY for multi-reference composites/edits, not raw quality). hailuo-02-standard (180cr), seedance-1.5-pro (150cr), kling (170cr/10s no-audio) are legacy/budget fallbacks."
3867
4020
  ),
3868
4021
  duration: z2.number().min(3).max(30).optional().describe(
3869
- "Video duration in seconds. kling: 5-30s, kling-3/kling-3-pro: 3-15s, sora2: 10-15s. Defaults to 5 seconds."
4022
+ "Video duration in seconds. kling: 5 or 10s \xB7 kling-3: 5 or 10s \xB7 kling-3-pro: 5/10/15s \xB7 grok-imagine/hailuo: 6 or 10s \xB7 wan-2.6: 5/10/15s \xB7 seedance family: 4-15s \xB7 veo3-fast/veo3-quality: fixed 8s (duration ignored). Out-of-range values are clamped server-side, never rejected. Defaults to 5 seconds."
3870
4023
  ),
3871
4024
  aspect_ratio: z2.enum(["16:9", "9:16", "1:1"]).optional().describe(
3872
4025
  "Video aspect ratio. 16:9 for YouTube/landscape, 9:16 for TikTok/Reels/Shorts, 1:1 for Instagram feed/square. Defaults to 16:9."
3873
4026
  ),
3874
4027
  enable_audio: z2.boolean().optional().describe(
3875
- "Enable native audio generation. Kling 2.6: doubles cost. Kling 3.0: 50% more (std 30/sec, pro 40/sec). 5+ languages."
4028
+ "Enable native audio generation. DEFAULTS TO FALSE (cost control). Cost multiplier when true: kling 2.6 ~2.65x (17->45 cr/sec), kling-3 1.5x (20->30 cr/sec), kling-3-pro ~1.5x (27->40 cr/sec). seedance-2/-fast generate audio natively regardless. 5+ languages."
4029
+ ),
4030
+ image_url: z2.string().optional().describe(
4031
+ "Start frame image URL for image-to-video (Kling 3.0 frame control)."
4032
+ ),
4033
+ end_frame_url: z2.string().optional().describe(
4034
+ "End frame image URL (Kling 3.0 only). Enables seamless loop transitions."
4035
+ ),
4036
+ project_id: z2.string().optional().describe(
4037
+ "Project ID to associate the video with (brand context is auto-injected from the project brand profile). Omit to generate without a project association."
3876
4038
  ),
3877
- image_url: z2.string().optional().describe("Start frame image URL for image-to-video (Kling 3.0 frame control)."),
3878
- end_frame_url: z2.string().optional().describe("End frame image URL (Kling 3.0 only). Enables seamless loop transitions."),
3879
4039
  response_format: z2.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
3880
4040
  },
3881
4041
  async ({
@@ -3886,6 +4046,7 @@ function registerContentTools(server) {
3886
4046
  enable_audio,
3887
4047
  image_url,
3888
4048
  end_frame_url,
4049
+ project_id,
3889
4050
  response_format
3890
4051
  }) => {
3891
4052
  const format = response_format ?? "text";
@@ -3924,9 +4085,14 @@ function registerContentTools(server) {
3924
4085
  model,
3925
4086
  duration: duration ?? 5,
3926
4087
  aspectRatio: aspect_ratio ?? "16:9",
3927
- enableAudio: enable_audio ?? true,
4088
+ // Default FALSE (2026-07-13): the old `?? true` default silently multiplied
4089
+ // kling-family costs (2.65x on kling 2.6) for callers that never asked for audio.
4090
+ enableAudio: enable_audio ?? false,
3928
4091
  ...image_url && { imageUrl: image_url },
3929
- ...end_frame_url && { endFrameUrl: end_frame_url }
4092
+ ...end_frame_url && { endFrameUrl: end_frame_url },
4093
+ // The server reads projectId and enforces
4094
+ // ownership via resolveProjectAndContent (index.ts:416-423).
4095
+ ...project_id && { projectId: project_id }
3930
4096
  },
3931
4097
  { timeoutMs: 3e4 }
3932
4098
  );
@@ -3998,7 +4164,7 @@ function registerContentTools(server) {
3998
4164
  );
3999
4165
  server.tool(
4000
4166
  "generate_image",
4001
- "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).",
4167
+ "Start an async AI image generation job \u2014 returns a job_id immediately. Poll with check_status every 5-15s until complete. Costs 15-50 credits depending on model. Use for social media posts, carousel slides, or as input to generate_video (image-to-video). Pass project_id so the asset is stored with the correct brand/project.",
4002
4168
  {
4003
4169
  prompt: z2.string().max(2e3).describe(
4004
4170
  "Text prompt describing the image to generate. Be specific about style, composition, colors, lighting, and subject matter."
@@ -4020,9 +4186,10 @@ function registerContentTools(server) {
4020
4186
  image_url: z2.string().optional().describe(
4021
4187
  "Reference image URL for image-to-image generation. Required for ideogram model. Optional for others."
4022
4188
  ),
4189
+ project_id: z2.string().optional().describe("Project ID to associate the generated image with."),
4023
4190
  response_format: z2.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
4024
4191
  },
4025
- async ({ prompt: prompt2, model, aspect_ratio, image_url, response_format }) => {
4192
+ async ({ prompt: prompt2, model, aspect_ratio, image_url, project_id, response_format }) => {
4026
4193
  const format = response_format ?? "text";
4027
4194
  const userId = await getDefaultUserId();
4028
4195
  const assetBudget = checkAssetBudget();
@@ -4058,7 +4225,8 @@ function registerContentTools(server) {
4058
4225
  prompt: prompt2,
4059
4226
  model,
4060
4227
  aspectRatio: aspect_ratio ?? "1:1",
4061
- imageUrl: image_url
4228
+ imageUrl: image_url,
4229
+ ...project_id && { projectId: project_id }
4062
4230
  },
4063
4231
  { timeoutMs: 3e4 }
4064
4232
  );
@@ -4097,7 +4265,8 @@ function registerContentTools(server) {
4097
4265
  jobId,
4098
4266
  taskId: data.taskId,
4099
4267
  asyncJobId: data.asyncJobId,
4100
- model: data.model
4268
+ model: data.model,
4269
+ projectId: project_id ?? null
4101
4270
  }),
4102
4271
  null,
4103
4272
  2
@@ -4124,7 +4293,7 @@ function registerContentTools(server) {
4124
4293
  );
4125
4294
  server.tool(
4126
4295
  "check_status",
4127
- '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.',
4296
+ '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. JSON shape (response_format=json) is stable across the whole poll lifecycle \u2014 canonical fields job_id, status, progress, result_url, r2_key, all_urls, error, credits_cost, created_at, completed_at are always present (never only camelCase or only snake_case); legacy aliases jobId/resultUrl/credits/createdAt/completedAt/error_message are also always populated for backward compatibility.',
4128
4297
  {
4129
4298
  job_id: z2.string().describe(
4130
4299
  "The job ID returned by generate_video or generate_image. This is the asyncJobId or taskId value."
@@ -4170,10 +4339,13 @@ function registerContentTools(server) {
4170
4339
  };
4171
4340
  }
4172
4341
  if (job.external_id && (job.status === "pending" || job.status === "processing")) {
4173
- const { data: liveStatus } = await callEdgeFunction("kie-task-status", {
4174
- taskId: job.external_id,
4175
- model: job.model
4176
- });
4342
+ const { data: liveStatus } = await callEdgeFunction(
4343
+ "kie-task-status",
4344
+ {
4345
+ taskId: job.external_id,
4346
+ model: job.model
4347
+ }
4348
+ );
4177
4349
  if (liveStatus) {
4178
4350
  const lines2 = [
4179
4351
  `Job: ${job.id}`,
@@ -4196,13 +4368,17 @@ function registerContentTools(server) {
4196
4368
  {
4197
4369
  type: "text",
4198
4370
  text: JSON.stringify(
4371
+ // v1.8.0: spread legacy live-branch fields first for
4372
+ // backward compatibility, then overlay
4373
+ // buildCheckStatusPayload's canonical snake_case fields +
4374
+ // both-alias set so a consumer never sees a different
4375
+ // field name depending on which branch served the poll.
4376
+ // lib/checkStatusShape.ts is the single stable shape —
4377
+ // it derives jobId/jobType/model/credits/createdAt from
4378
+ // `job` + `liveStatus`; do not duplicate them here.
4199
4379
  asEnvelope2({
4200
- jobId: job.id,
4201
- jobType: job.job_type,
4202
- model: job.model,
4203
4380
  ...liveStatus,
4204
- credits: job.credits_cost,
4205
- createdAt: job.created_at
4381
+ ...buildCheckStatusPayload(job, liveStatus)
4206
4382
  }),
4207
4383
  null,
4208
4384
  2
@@ -4229,7 +4405,7 @@ function registerContentTools(server) {
4229
4405
  const filename = segments[segments.length - 1] || "media";
4230
4406
  lines.push(`Media ready: ${filename}`);
4231
4407
  lines.push(
4232
- "(Pass job_id directly to schedule_post, or use get_media_url with job_id for a download link)"
4408
+ "(Pass job_id directly to schedule_post, or pass response_format=json's r2_key to get_media_url for a download link)"
4233
4409
  );
4234
4410
  } else {
4235
4411
  lines.push(`Result URL: ${job.result_url}`);
@@ -4253,11 +4429,15 @@ function registerContentTools(server) {
4253
4429
  if (format === "json") {
4254
4430
  const enriched = {
4255
4431
  ...job,
4256
- r2_key: job.result_url && !job.result_url.startsWith("http") ? job.result_url : null,
4257
- all_urls: allUrls ?? null
4432
+ ...buildCheckStatusPayload(job)
4258
4433
  };
4259
4434
  return {
4260
- content: [{ type: "text", text: JSON.stringify(asEnvelope2(enriched), null, 2) }]
4435
+ content: [
4436
+ {
4437
+ type: "text",
4438
+ text: JSON.stringify(asEnvelope2(enriched), null, 2)
4439
+ }
4440
+ ]
4261
4441
  };
4262
4442
  }
4263
4443
  return {
@@ -4267,7 +4447,7 @@ function registerContentTools(server) {
4267
4447
  );
4268
4448
  server.tool(
4269
4449
  "create_storyboard",
4270
- "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.",
4450
+ "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 and project_id for consistent, project-scoped production. Costs 10 credits.",
4271
4451
  {
4272
4452
  concept: z2.string().max(2e3).describe(
4273
4453
  '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").'
@@ -4275,7 +4455,15 @@ function registerContentTools(server) {
4275
4455
  brand_context: z2.string().max(3e3).optional().describe(
4276
4456
  "Brand context JSON from extract_brand. Include colors, voice tone, visual style keywords for consistent branding across frames."
4277
4457
  ),
4278
- platform: z2.enum(["tiktok", "instagram-reels", "youtube-shorts", "youtube", "general"]).describe("Target platform. Determines aspect ratio, duration, and pacing."),
4458
+ platform: z2.enum([
4459
+ "tiktok",
4460
+ "instagram-reels",
4461
+ "youtube-shorts",
4462
+ "youtube",
4463
+ "general"
4464
+ ]).describe(
4465
+ "Target platform. Determines aspect ratio, duration, and pacing."
4466
+ ),
4279
4467
  target_duration: z2.number().min(5).max(120).optional().describe(
4280
4468
  "Target total duration in seconds. Defaults to 30s for short-form, 60s for YouTube."
4281
4469
  ),
@@ -4283,7 +4471,10 @@ function registerContentTools(server) {
4283
4471
  style: z2.string().optional().describe(
4284
4472
  'Visual style direction (e.g., "cinematic", "anime", "documentary", "motion graphics").'
4285
4473
  ),
4286
- response_format: z2.enum(["text", "json"]).optional().describe("Response format. Defaults to json for structured storyboard data.")
4474
+ project_id: z2.string().optional().describe("Project ID for brand-scoped generation and attribution."),
4475
+ response_format: z2.enum(["text", "json"]).optional().describe(
4476
+ "Response format. Defaults to json for structured storyboard data."
4477
+ )
4287
4478
  },
4288
4479
  async ({
4289
4480
  concept,
@@ -4292,10 +4483,15 @@ function registerContentTools(server) {
4292
4483
  target_duration,
4293
4484
  num_scenes,
4294
4485
  style,
4486
+ project_id,
4295
4487
  response_format
4296
4488
  }) => {
4297
4489
  const format = response_format ?? "json";
4298
- const isShortForm = ["tiktok", "instagram-reels", "youtube-shorts"].includes(platform3);
4490
+ const isShortForm = [
4491
+ "tiktok",
4492
+ "instagram-reels",
4493
+ "youtube-shorts"
4494
+ ].includes(platform3);
4299
4495
  const duration = target_duration ?? (isShortForm ? 30 : 60);
4300
4496
  const scenes = num_scenes ?? (isShortForm ? 7 : 10);
4301
4497
  const aspectRatio = isShortForm ? "9:16" : "16:9";
@@ -4370,23 +4566,45 @@ Return ONLY valid JSON in this exact format:
4370
4566
  prompt: storyboardPrompt,
4371
4567
  type: "storyboard",
4372
4568
  model: "gemini-2.5-flash",
4373
- responseFormat: "json"
4569
+ responseFormat: "json",
4570
+ ...project_id && { projectId: project_id }
4374
4571
  },
4375
4572
  { timeoutMs: 6e4 }
4376
4573
  );
4377
4574
  if (error) {
4378
4575
  return {
4379
- content: [{ type: "text", text: `Storyboard generation failed: ${error}` }],
4576
+ content: [
4577
+ {
4578
+ type: "text",
4579
+ text: `Storyboard generation failed: ${error}`
4580
+ }
4581
+ ],
4582
+ isError: true
4583
+ };
4584
+ }
4585
+ const rawContent = data?.content?.trim() || data?.text?.trim() || "";
4586
+ if (!rawContent) {
4587
+ return {
4588
+ content: [
4589
+ {
4590
+ type: "text",
4591
+ text: "Storyboard generation failed: the AI service returned an empty response."
4592
+ }
4593
+ ],
4380
4594
  isError: true
4381
4595
  };
4382
4596
  }
4383
- const rawContent = data?.content ?? "";
4384
4597
  addCreditsUsed(estimatedCost);
4385
4598
  if (format === "json") {
4386
4599
  try {
4387
4600
  const parsed = JSON.parse(rawContent);
4388
4601
  return {
4389
- content: [{ type: "text", text: JSON.stringify(asEnvelope2(parsed), null, 2) }]
4602
+ content: [
4603
+ {
4604
+ type: "text",
4605
+ text: JSON.stringify(asEnvelope2(parsed), null, 2)
4606
+ }
4607
+ ]
4390
4608
  };
4391
4609
  } catch {
4392
4610
  return {
@@ -4411,16 +4629,17 @@ Return ONLY valid JSON in this exact format:
4411
4629
  );
4412
4630
  server.tool(
4413
4631
  "generate_voiceover",
4414
- "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.",
4632
+ "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. Pass project_id to keep the asset with the correct brand/project. Costs 15 credits per generation.",
4415
4633
  {
4416
4634
  text: z2.string().max(5e3).describe("The script/text to convert to speech."),
4417
4635
  voice: z2.enum(["rachel", "domi"]).optional().describe(
4418
4636
  "Voice selection. rachel=warm female, domi=confident female. Defaults to rachel."
4419
4637
  ),
4420
4638
  speed: z2.number().min(0.5).max(2).optional().describe("Speech speed multiplier. 1.0 is normal. Defaults to 1.0."),
4639
+ project_id: z2.string().optional().describe("Project ID to associate the generated voiceover with."),
4421
4640
  response_format: z2.enum(["text", "json"]).optional().describe("Response format. Defaults to text.")
4422
4641
  },
4423
- async ({ text, voice, speed, response_format }) => {
4642
+ async ({ text, voice, speed, project_id, response_format }) => {
4424
4643
  const format = response_format ?? "text";
4425
4644
  const userId = await getDefaultUserId();
4426
4645
  const estimatedCost = 15;
@@ -4431,7 +4650,10 @@ Return ONLY valid JSON in this exact format:
4431
4650
  isError: true
4432
4651
  };
4433
4652
  }
4434
- const rateLimit = checkRateLimit("posting", `generate_voiceover:${userId}`);
4653
+ const rateLimit = checkRateLimit(
4654
+ "posting",
4655
+ `generate_voiceover:${userId}`
4656
+ );
4435
4657
  if (!rateLimit.allowed) {
4436
4658
  return {
4437
4659
  content: [
@@ -4452,20 +4674,29 @@ Return ONLY valid JSON in this exact format:
4452
4674
  {
4453
4675
  text,
4454
4676
  voiceId: ELEVENLABS_VOICE_IDS[voice ?? "rachel"],
4455
- speed: speed ?? 1
4677
+ speed: speed ?? 1,
4678
+ ...project_id && { projectId: project_id }
4456
4679
  },
4457
4680
  { timeoutMs: 6e4 }
4458
4681
  );
4459
4682
  if (error) {
4460
4683
  return {
4461
- content: [{ type: "text", text: `Voiceover generation failed: ${error}` }],
4684
+ content: [
4685
+ {
4686
+ type: "text",
4687
+ text: `Voiceover generation failed: ${error}`
4688
+ }
4689
+ ],
4462
4690
  isError: true
4463
4691
  };
4464
4692
  }
4465
4693
  if (!data?.audioUrl) {
4466
4694
  return {
4467
4695
  content: [
4468
- { type: "text", text: "Voiceover generation failed: no audio URL returned." }
4696
+ {
4697
+ type: "text",
4698
+ text: "Voiceover generation failed: no audio URL returned."
4699
+ }
4469
4700
  ],
4470
4701
  isError: true
4471
4702
  };
@@ -4480,7 +4711,8 @@ Return ONLY valid JSON in this exact format:
4480
4711
  asEnvelope2({
4481
4712
  audioUrl: data.audioUrl,
4482
4713
  durationSeconds: data.durationSeconds,
4483
- voice: voice ?? "rachel"
4714
+ voice: voice ?? "rachel",
4715
+ projectId: project_id ?? null
4484
4716
  }),
4485
4717
  null,
4486
4718
  2
@@ -4527,20 +4759,30 @@ Return ONLY valid JSON in this exact format:
4527
4759
  "Carousel template. hormozi-authority: bold typography, one idea per slide, dark backgrounds. educational-series: numbered tips. Default: hormozi-authority."
4528
4760
  ),
4529
4761
  slide_count: z2.number().min(3).max(10).optional().describe("Number of slides (3-10). Default: 7."),
4530
- aspect_ratio: z2.enum(["1:1", "4:5", "9:16"]).optional().describe("Aspect ratio. 1:1 square (default), 4:5 portrait, 9:16 story."),
4762
+ aspect_ratio: z2.enum(["1:1", "4:5", "9:16"]).optional().describe(
4763
+ "Aspect ratio. 1:1 square (default), 4:5 portrait, 9:16 story."
4764
+ ),
4531
4765
  style: z2.enum(["minimal", "bold", "professional", "playful", "hormozi"]).optional().describe(
4532
4766
  "Visual style. hormozi: black bg, bold white text, gold accents. Default: hormozi (when using hormozi-authority template)."
4533
4767
  ),
4534
4768
  hook: z2.string().max(300).optional().describe(
4535
4769
  "Explicit hook/opener for slide 1. Overrides any hook derived from topic. Keep under 15 words."
4536
4770
  ),
4537
- hook_family: z2.enum(["curiosity", "authority", "pain_point", "contrarian", "data_driven"]).optional().describe(
4538
- "Hook family tag. Persisted with the carousel so bandit learners can attribute engagement to hook pattern."
4771
+ hook_family: z2.enum([
4772
+ "curiosity",
4773
+ "authority",
4774
+ "pain_point",
4775
+ "contrarian",
4776
+ "data_driven"
4777
+ ]).optional().describe(
4778
+ "Hook family tag. Persisted with the carousel so downstream analytics can attribute engagement to hook pattern."
4539
4779
  ),
4540
4780
  cta_text: z2.string().max(200).optional().describe("Explicit CTA copy for the final slide."),
4541
4781
  cta_url: z2.string().url().optional().describe("URL promoted on the CTA slide."),
4542
4782
  tone: z2.string().max(200).optional().describe("Voice/tone override. Composes with brand profile voice."),
4543
- constraints: z2.string().max(500).optional().describe('Content constraints. Example: "No fabricated statistics. Sentence case only."'),
4783
+ constraints: z2.string().max(500).optional().describe(
4784
+ 'Content constraints. Example: "No fabricated statistics. Sentence case only."'
4785
+ ),
4544
4786
  platform: z2.enum(["linkedin", "instagram", "tiktok", "x"]).optional().describe("Target platform. Affects tone and format guardrails."),
4545
4787
  project_id: z2.string().optional().describe("Project ID to associate the carousel with."),
4546
4788
  response_format: z2.enum(["text", "json"]).optional().describe("Response format. Defaults to json.")
@@ -4575,7 +4817,10 @@ Return ONLY valid JSON in this exact format:
4575
4817
  };
4576
4818
  }
4577
4819
  const userId = await getDefaultUserId();
4578
- const rateLimit = checkRateLimit("posting", `generate_carousel:${userId}`);
4820
+ const rateLimit = checkRateLimit(
4821
+ "posting",
4822
+ `generate_carousel:${userId}`
4823
+ );
4579
4824
  if (!rateLimit.allowed) {
4580
4825
  return {
4581
4826
  content: [
@@ -4608,13 +4853,23 @@ Return ONLY valid JSON in this exact format:
4608
4853
  );
4609
4854
  if (error) {
4610
4855
  return {
4611
- content: [{ type: "text", text: `Carousel generation failed: ${error}` }],
4856
+ content: [
4857
+ {
4858
+ type: "text",
4859
+ text: `Carousel generation failed: ${error}`
4860
+ }
4861
+ ],
4612
4862
  isError: true
4613
4863
  };
4614
4864
  }
4615
4865
  if (!data?.carousel) {
4616
4866
  return {
4617
- content: [{ type: "text", text: "Carousel generation returned no data." }],
4867
+ content: [
4868
+ {
4869
+ type: "text",
4870
+ text: "Carousel generation returned no data."
4871
+ }
4872
+ ],
4618
4873
  isError: true
4619
4874
  };
4620
4875
  }
@@ -4662,7 +4917,7 @@ Return ONLY valid JSON in this exact format:
4662
4917
  }
4663
4918
  );
4664
4919
  }
4665
- var VIDEO_CREDIT_ESTIMATES, IMAGE_CREDIT_ESTIMATES;
4920
+ var VIDEO_CREDIT_ESTIMATES, VIDEO_MODEL_ENUM, IMAGE_CREDIT_ESTIMATES;
4666
4921
  var init_content2 = __esm({
4667
4922
  "src/tools/content.ts"() {
4668
4923
  "use strict";
@@ -4670,17 +4925,36 @@ var init_content2 = __esm({
4670
4925
  init_rate_limit();
4671
4926
  init_supabase();
4672
4927
  init_version();
4928
+ init_checkStatusShape();
4673
4929
  init_budget();
4674
4930
  VIDEO_CREDIT_ESTIMATES = {
4675
- "veo3-fast": 200,
4676
- "veo3-quality": 1e3,
4677
- "runway-aleph": 340,
4678
- sora2: 500,
4679
- "sora2-pro": 1500,
4680
- kling: 170,
4931
+ "seedance-2-fast": 264,
4681
4932
  "kling-3": 100,
4682
- "kling-3-pro": 135
4933
+ "grok-imagine": 30,
4934
+ "veo3-fast": 65,
4935
+ "kling-3-pro": 135,
4936
+ "seedance-2": 328,
4937
+ "veo3-quality": 1e3,
4938
+ "wan-2.6": 105,
4939
+ "gemini-omni-video": 126,
4940
+ "hailuo-02-standard": 180,
4941
+ "seedance-1.5-pro": 150,
4942
+ kling: 170
4683
4943
  };
4944
+ VIDEO_MODEL_ENUM = [
4945
+ "seedance-2-fast",
4946
+ "kling-3",
4947
+ "grok-imagine",
4948
+ "veo3-fast",
4949
+ "kling-3-pro",
4950
+ "seedance-2",
4951
+ "veo3-quality",
4952
+ "wan-2.6",
4953
+ "gemini-omni-video",
4954
+ "hailuo-02-standard",
4955
+ "seedance-1.5-pro",
4956
+ "kling"
4957
+ ];
4684
4958
  IMAGE_CREDIT_ESTIMATES = {
4685
4959
  midjourney: 20,
4686
4960
  "nano-banana": 15,
@@ -4750,6 +5024,7 @@ var init_sanitize_error = __esm({
4750
5024
  });
4751
5025
 
4752
5026
  // src/lib/ssrf.ts
5027
+ import { promises as dnsPromises } from "node:dns";
4753
5028
  function isBlockedIP(ip) {
4754
5029
  const normalized = ip.replace(/^\[/, "").replace(/\]$/, "");
4755
5030
  if (normalized.includes(":")) {
@@ -4803,8 +5078,7 @@ async function validateUrlForSSRF(urlString) {
4803
5078
  let resolvedIP;
4804
5079
  if (!isIPAddress(hostname)) {
4805
5080
  try {
4806
- const dns = await import("node:dns");
4807
- const resolver = new dns.promises.Resolver();
5081
+ const resolver = new dnsPromises.Resolver();
4808
5082
  const resolvedIPs = [];
4809
5083
  try {
4810
5084
  const aRecords = await resolver.resolve4(hostname);
@@ -4930,6 +5204,24 @@ function asEnvelope3(data) {
4930
5204
  data
4931
5205
  };
4932
5206
  }
5207
+ function accountEffectiveStatus(account) {
5208
+ return account.effective_status || account.status;
5209
+ }
5210
+ function isUsableAccount(account) {
5211
+ const status = accountEffectiveStatus(account);
5212
+ return status === "active" || status === "expires_soon";
5213
+ }
5214
+ function formatAccountChoice(account) {
5215
+ const name = account.username ? `@${account.username}` : "unnamed";
5216
+ const project = account.project_id ? `project_id=${account.project_id}` : "project_id=unassigned";
5217
+ const refresh = account.has_refresh_token ? "OAuth 2.0 refresh" : "no refresh token";
5218
+ return `${account.id} (${name}, ${project}, ${accountEffectiveStatus(account)}, ${refresh})`;
5219
+ }
5220
+ function requestedAccountIdForPlatform(accountId, accountIds, platform3) {
5221
+ if (accountId) return accountId;
5222
+ if (!accountIds) return void 0;
5223
+ return accountIds[platform3] || accountIds[platform3.toLowerCase()];
5224
+ }
4933
5225
  function isAlreadyR2Signed(url) {
4934
5226
  try {
4935
5227
  const u = new URL(url);
@@ -5073,16 +5365,18 @@ function registerDistributionTools(server) {
5073
5365
  schedule_at: z3.string().optional().describe(
5074
5366
  'ISO 8601 UTC datetime for scheduled posting (e.g. "2026-03-20T14:00:00Z"). Omit to post immediately. Must be in the future.'
5075
5367
  ),
5076
- project_id: z3.string().optional().describe("Social Neuron project ID to associate this post with."),
5368
+ project_id: z3.string().optional().describe(
5369
+ "Social Neuron brand/project ID to associate this post with. Provide this when the account has multiple brands so brand voice and connected account routing stay scoped to the right brand."
5370
+ ),
5077
5371
  response_format: z3.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text."),
5078
5372
  attribution: z3.boolean().optional().describe(
5079
5373
  'If true, appends "Created with Social Neuron" to the caption. Default: false.'
5080
5374
  ),
5081
5375
  account_id: z3.string().optional().describe(
5082
- "Connected account ID to post from. Use list_connected_accounts to find the right ID. Required when multiple accounts exist for the same platform."
5376
+ "Connected account ID to post from. Use list_connected_accounts to find the right ID. Required when multiple accounts exist for the same platform. If project_id is provided, the account must belong to that project or be unassigned."
5083
5377
  ),
5084
5378
  account_ids: z3.record(z3.string(), z3.string()).optional().describe(
5085
- 'Per-platform account IDs when posting to multiple platforms. Example: {"twitter": "abc123", "instagram": "def456"}. Use list_connected_accounts to find IDs.'
5379
+ 'Per-platform account IDs when posting to multiple platforms. Example: {"twitter": "abc123", "instagram": "def456"}. Use list_connected_accounts with the same project_id to find IDs.'
5086
5380
  ),
5087
5381
  auto_rehost: z3.boolean().optional().describe(
5088
5382
  "Whether to persist non-R2 media_url/media_urls into R2 before posting. Default: true. Set to false only if you know the source URL will outlive the scheduling window and every target platform supports URL ingest."
@@ -5292,22 +5586,53 @@ function registerDistributionTools(server) {
5292
5586
  isError: true
5293
5587
  };
5294
5588
  }
5295
- const { data: accountsData } = await callEdgeFunction("mcp-data", { action: "connected-accounts" }, { timeoutMs: 1e4 });
5589
+ const { data: accountsData } = await callEdgeFunction(
5590
+ "mcp-data",
5591
+ {
5592
+ action: "connected-accounts",
5593
+ ...project_id ? { projectId: project_id, project_id } : {}
5594
+ },
5595
+ { timeoutMs: 1e4 }
5596
+ );
5296
5597
  if (accountsData?.accounts) {
5297
5598
  const accounts = accountsData.accounts;
5298
5599
  const issues = [];
5299
5600
  for (const platform3 of normalizedPlatforms) {
5300
5601
  const platformAccounts = accounts.filter(
5301
- (a) => a.platform.toLowerCase() === platform3.toLowerCase() && a.status === "active"
5602
+ (a) => a.platform.toLowerCase() === platform3.toLowerCase() && isUsableAccount(a)
5302
5603
  );
5604
+ const requestedAccountId = requestedAccountIdForPlatform(
5605
+ account_id,
5606
+ account_ids,
5607
+ platform3
5608
+ );
5609
+ if (requestedAccountId) {
5610
+ const selected = accounts.find((a) => a.id === requestedAccountId);
5611
+ if (!selected) {
5612
+ issues.push(
5613
+ `${platform3}: Account "${requestedAccountId}" is not available${project_id ? ` for project_id ${project_id}` : ""}. Call \`list_connected_accounts\`${project_id ? " with the same project_id" : ""} and choose one of the returned IDs.`
5614
+ );
5615
+ } else if (selected.platform.toLowerCase() !== platform3.toLowerCase()) {
5616
+ issues.push(
5617
+ `${platform3}: Account "${requestedAccountId}" belongs to ${selected.platform}, not ${platform3}.`
5618
+ );
5619
+ } else if (!isUsableAccount(selected)) {
5620
+ issues.push(
5621
+ `${platform3}: Account "${selected.username || selected.id}" is ${accountEffectiveStatus(selected)}. Reconnect at socialneuron.com/settings/connections.`
5622
+ );
5623
+ } else if (project_id && selected.project_id && selected.project_id !== project_id) {
5624
+ issues.push(
5625
+ `${platform3}: Account "${selected.username || selected.id}" is bound to project_id ${selected.project_id}, not ${project_id}. Use the selected brand's account or reconnect/assign it in Settings > Integrations.`
5626
+ );
5627
+ }
5628
+ continue;
5629
+ }
5303
5630
  if (platformAccounts.length === 0) {
5304
5631
  issues.push(
5305
- `${platform3}: not connected yet. This is a one-time browser setup on socialneuron.com \u2014 NOT another OAuth in Claude. Call \`start_platform_connection\` with platform="${platform3.toLowerCase()}" to get a deep link, ask the user to open it in their browser and approve on the platform, then call \`wait_for_connection\` before retrying schedule_post.`
5632
+ `${platform3}: not connected yet. This is a one-time browser setup on socialneuron.com \u2014 NOT another OAuth in Claude. Call \`start_platform_connection\` with platform="${platform3.toLowerCase()}"${project_id ? ` and project_id="${project_id}"` : ""} to get a deep link, ask the user to open it in their browser and approve on the platform, then call \`wait_for_connection\` before retrying schedule_post.`
5306
5633
  );
5307
- } else if (platformAccounts.length > 1 && !account_id && !account_ids?.[platform3.toLowerCase()]) {
5308
- const accountList = platformAccounts.map(
5309
- (a) => ` - ${a.id} (${a.username || "unnamed"}${a.has_refresh_token ? ", OAuth 2.0" : ", no refresh token"})`
5310
- ).join("\n");
5634
+ } else if (platformAccounts.length > 1) {
5635
+ const accountList = platformAccounts.map((a) => ` - ${formatAccountChoice(a)}`).join("\n");
5311
5636
  issues.push(
5312
5637
  `${platform3}: Multiple accounts found. Specify account_id or account_ids to choose:
5313
5638
  ${accountList}`
@@ -5462,13 +5787,21 @@ Created with Social Neuron`;
5462
5787
  );
5463
5788
  server.tool(
5464
5789
  "list_connected_accounts",
5465
- "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.",
5790
+ "Check which social platforms have active OAuth connections for posting. Call this before schedule_post to verify credentials. Pass project_id to list the accounts for a specific brand/project, then pass the returned account id as account_id/account_ids when posting. If a platform is missing or expired, the user needs to reconnect at socialneuron.com/settings/connections.",
5466
5791
  {
5792
+ project_id: z3.string().optional().describe(
5793
+ "Brand/project ID to scope connected accounts. Use the same project_id when calling schedule_post."
5794
+ ),
5795
+ include_all: z3.boolean().optional().describe("If true, include expired or inactive accounts as well as usable accounts."),
5467
5796
  response_format: z3.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
5468
5797
  },
5469
- async ({ response_format }) => {
5798
+ async ({ project_id, include_all, response_format }) => {
5470
5799
  const format = response_format ?? "text";
5471
- const { data: result, error: efError } = await callEdgeFunction("mcp-data", { action: "connected-accounts" });
5800
+ const { data: result, error: efError } = await callEdgeFunction("mcp-data", {
5801
+ action: "connected-accounts",
5802
+ ...project_id ? { projectId: project_id, project_id } : {},
5803
+ ...include_all ? { includeAll: true } : {}
5804
+ });
5472
5805
  if (efError || !result?.success) {
5473
5806
  return {
5474
5807
  content: [
@@ -5501,12 +5834,17 @@ Created with Social Neuron`;
5501
5834
  ]
5502
5835
  };
5503
5836
  }
5504
- const lines = [`${accounts.length} connected account(s):`, ""];
5837
+ const lines = [
5838
+ `${accounts.length} connected account(s)${project_id ? ` for project ${project_id}` : ""}:`,
5839
+ ""
5840
+ ];
5505
5841
  for (const account of accounts) {
5506
5842
  const name = account.username || "(unnamed)";
5507
5843
  const platformLower = account.platform.toLowerCase();
5844
+ const project = account.project_id ? `project_id=${account.project_id}` : "project_id=unassigned";
5845
+ const status = accountEffectiveStatus(account);
5508
5846
  lines.push(
5509
- ` ${platformLower}: ${name} (connected ${account.created_at.split("T")[0]})`
5847
+ ` ${platformLower}: ${name} | id=${account.id} | ${project} | status=${status} (connected ${account.created_at.split("T")[0]})`
5510
5848
  );
5511
5849
  }
5512
5850
  if (format === "json") {
@@ -6714,7 +7052,8 @@ function registerMediaTools(server) {
6714
7052
  },
6715
7053
  async ({ r2_key, response_format }) => {
6716
7054
  const format = response_format ?? "text";
6717
- const { data, error } = await callEdgeFunction("get-signed-url", { r2Key: r2_key, operation: "get" }, { timeoutMs: 1e4 });
7055
+ const normalizedR2Key = r2_key.startsWith("r2://") ? r2_key.slice("r2://".length) : r2_key;
7056
+ const { data, error } = await callEdgeFunction("get-signed-url", { r2Key: normalizedR2Key, operation: "get" }, { timeoutMs: 1e4 });
6718
7057
  const signedDownloadUrl = data?.url ?? data?.signedUrl;
6719
7058
  if (error) {
6720
7059
  return {
@@ -6799,7 +7138,7 @@ function asEnvelope4(data) {
6799
7138
  function registerAnalyticsTools(server) {
6800
7139
  server.tool(
6801
7140
  "fetch_analytics",
6802
- "Get post performance metrics \u2014 views, likes, comments, shares, and engagement rate. Filter by platform, time range (default 30 days), or specific content_id. Call refresh_platform_analytics first if data seems stale. Results sorted by most recent capture.",
7141
+ "Get project-scoped post performance metrics \u2014 views, likes, comments, shares, and engagement rate. Filter by platform, time range (default 30 days), or specific content_id. Call refresh_platform_analytics first if data seems stale. Results sorted by most recent capture.",
6803
7142
  {
6804
7143
  platform: z5.enum([
6805
7144
  "youtube",
@@ -6817,19 +7156,22 @@ function registerAnalyticsTools(server) {
6817
7156
  content_id: z5.string().uuid().optional().describe(
6818
7157
  "Filter to a specific content_history ID to see performance of one piece of content."
6819
7158
  ),
7159
+ project_id: z5.string().optional().describe("Project ID. Defaults to the active project context."),
6820
7160
  limit: z5.number().min(1).max(100).optional().describe("Maximum number of posts to return. Defaults to 20."),
6821
7161
  response_format: z5.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
6822
7162
  },
6823
- async ({ platform: platform3, days, content_id, limit, response_format }) => {
7163
+ async ({ platform: platform3, days, content_id, project_id, limit, response_format }) => {
6824
7164
  const format = response_format ?? "text";
6825
7165
  const lookbackDays = days ?? 30;
6826
7166
  const maxPosts = limit ?? 20;
7167
+ const resolvedProjectId = project_id ?? await getDefaultProjectId() ?? void 0;
6827
7168
  const { data: result, error: efError } = await callEdgeFunction("mcp-data", {
6828
7169
  action: "analytics",
6829
7170
  platform: platform3,
6830
7171
  days: lookbackDays,
6831
7172
  limit: maxPosts,
6832
- contentId: content_id
7173
+ contentId: content_id,
7174
+ ...resolvedProjectId ? { projectId: resolvedProjectId, project_id: resolvedProjectId } : {}
6833
7175
  });
6834
7176
  if (efError) {
6835
7177
  return {
@@ -6899,14 +7241,19 @@ function registerAnalyticsTools(server) {
6899
7241
  );
6900
7242
  server.tool(
6901
7243
  "refresh_platform_analytics",
6902
- "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.",
7244
+ "Queue analytics refresh jobs for posts from the last 7 days in one project across its connected platforms. Call this before fetch_analytics if you need fresh data. Returns immediately \u2014 data updates asynchronously over the next 1-5 minutes.",
6903
7245
  {
7246
+ project_id: z5.string().optional().describe("Project ID. Defaults to the active project context."),
6904
7247
  response_format: z5.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
6905
7248
  },
6906
- async ({ response_format }) => {
7249
+ async ({ project_id, response_format }) => {
6907
7250
  const format = response_format ?? "text";
6908
7251
  const userId = await getDefaultUserId();
6909
- const rateLimit = checkRateLimit("posting", `refresh_platform_analytics:${userId}`);
7252
+ const resolvedProjectId = project_id ?? await getDefaultProjectId() ?? void 0;
7253
+ const rateLimit = checkRateLimit(
7254
+ "posting",
7255
+ `refresh_platform_analytics:${userId}:${resolvedProjectId ?? "default"}`
7256
+ );
6910
7257
  if (!rateLimit.allowed) {
6911
7258
  return {
6912
7259
  content: [
@@ -6918,7 +7265,10 @@ function registerAnalyticsTools(server) {
6918
7265
  isError: true
6919
7266
  };
6920
7267
  }
6921
- const { data, error } = await callEdgeFunction("fetch-analytics", { userId });
7268
+ const { data, error } = await callEdgeFunction("fetch-analytics", {
7269
+ userId,
7270
+ ...resolvedProjectId ? { projectId: resolvedProjectId, project_id: resolvedProjectId } : {}
7271
+ });
6922
7272
  if (error) {
6923
7273
  return {
6924
7274
  content: [{ type: "text", text: `Error refreshing analytics: ${error}` }],
@@ -6947,7 +7297,8 @@ function registerAnalyticsTools(server) {
6947
7297
  success: true,
6948
7298
  postsProcessed: result.postsProcessed,
6949
7299
  queued,
6950
- errored
7300
+ errored,
7301
+ projectId: resolvedProjectId ?? null
6951
7302
  });
6952
7303
  return {
6953
7304
  structuredContent,
@@ -8050,22 +8401,25 @@ function asEnvelope6(data) {
8050
8401
  function registerInsightsTools(server) {
8051
8402
  server.tool(
8052
8403
  "get_performance_insights",
8053
- "Query performance insights derived from post analytics. Returns metrics like engagement rate, view velocity, and click rate aggregated over time. Use this to understand what content is performing well.",
8404
+ "Query project-scoped performance insights derived from post analytics. Returns metrics like engagement rate, view velocity, and click rate aggregated over time. Use this to understand what content is performing well.",
8054
8405
  {
8055
8406
  insight_type: z9.enum(["top_hooks", "optimal_timing", "best_models", "competitor_patterns"]).optional().describe("Filter to a specific insight type."),
8056
8407
  days: z9.number().min(1).max(90).optional().describe("Number of days to look back. Defaults to 30. Max 90."),
8057
8408
  limit: z9.number().min(1).max(50).optional().describe("Maximum number of insights to return. Defaults to 10."),
8409
+ project_id: z9.string().optional().describe("Project ID. Defaults to the active project context."),
8058
8410
  response_format: z9.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
8059
8411
  },
8060
- async ({ insight_type, days, limit, response_format }) => {
8412
+ async ({ insight_type, days, limit, project_id, response_format }) => {
8061
8413
  const format = response_format ?? "text";
8062
8414
  const lookbackDays = days ?? 30;
8063
8415
  const maxRows = limit ?? 10;
8064
8416
  const effectiveDays = Math.min(lookbackDays, MAX_INSIGHT_AGE_DAYS);
8417
+ const resolvedProjectId = project_id ?? await getDefaultProjectId() ?? void 0;
8065
8418
  const { data: result, error: efError } = await callEdgeFunction("mcp-data", {
8066
8419
  action: "performance-insights",
8067
8420
  days: effectiveDays,
8068
- limit: maxRows
8421
+ limit: maxRows,
8422
+ ...resolvedProjectId ? { projectId: resolvedProjectId, project_id: resolvedProjectId } : {}
8069
8423
  });
8070
8424
  if (efError || !result?.success) {
8071
8425
  return {
@@ -8152,19 +8506,22 @@ function registerInsightsTools(server) {
8152
8506
  );
8153
8507
  server.tool(
8154
8508
  "get_best_posting_times",
8155
- "Analyze post analytics data to find the best times to post for maximum engagement. Returns the top 5 time slots (day of week + hour) ranked by average engagement.",
8509
+ "Analyze project-scoped post analytics data to find the best times to post for maximum engagement. Returns the top 5 time slots (day of week + hour) ranked by average engagement.",
8156
8510
  {
8157
8511
  platform: z9.enum(PLATFORM_ENUM).optional().describe("Filter to a specific platform."),
8158
8512
  days: z9.number().min(1).max(90).optional().describe("Number of days to analyze. Defaults to 30. Max 90."),
8513
+ project_id: z9.string().optional().describe("Project ID. Defaults to the active project context."),
8159
8514
  response_format: z9.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
8160
8515
  },
8161
- async ({ platform: platform3, days, response_format }) => {
8516
+ async ({ platform: platform3, days, project_id, response_format }) => {
8162
8517
  const format = response_format ?? "text";
8163
8518
  const lookbackDays = days ?? 30;
8519
+ const resolvedProjectId = project_id ?? await getDefaultProjectId() ?? void 0;
8164
8520
  const { data: result, error: efError } = await callEdgeFunction("mcp-data", {
8165
8521
  action: "best-posting-times",
8166
8522
  days: lookbackDays,
8167
- platform: platform3 ?? void 0
8523
+ platform: platform3 ?? void 0,
8524
+ ...resolvedProjectId ? { projectId: resolvedProjectId, project_id: resolvedProjectId } : {}
8168
8525
  });
8169
8526
  if (efError || !result?.success) {
8170
8527
  return {
@@ -8278,6 +8635,7 @@ var init_insights = __esm({
8278
8635
  "src/tools/insights.ts"() {
8279
8636
  "use strict";
8280
8637
  init_edge_function();
8638
+ init_supabase();
8281
8639
  init_version();
8282
8640
  MAX_INSIGHT_AGE_DAYS = 30;
8283
8641
  PLATFORM_ENUM = [
@@ -9321,7 +9679,7 @@ Schedule: ${JSON.stringify(updated.schedule_config)}`
9321
9679
  }
9322
9680
  const statusData = {
9323
9681
  activeConfigs: result?.activeConfigs ?? 0,
9324
- recentRuns: [],
9682
+ recentRuns: result?.recentRuns ?? result?.recent_runs ?? [],
9325
9683
  pendingApprovals: result?.pendingApprovals ?? 0
9326
9684
  };
9327
9685
  if (format === "json") {
@@ -9343,8 +9701,20 @@ ${"=".repeat(40)}
9343
9701
  text += `Pending Approvals: ${statusData.pendingApprovals}
9344
9702
 
9345
9703
  `;
9346
- text += `No recent runs.
9704
+ if (statusData.recentRuns.length === 0) {
9705
+ text += `No recent runs.
9706
+ `;
9707
+ } else {
9708
+ text += `Recent Runs (${statusData.recentRuns.length}):
9709
+ `;
9710
+ for (const run of statusData.recentRuns.slice(0, 10)) {
9711
+ const id = String(run.id ?? run.run_id ?? "unknown");
9712
+ const status = String(run.status ?? "unknown");
9713
+ const credits = run.credits_used ?? run.creditsUsed;
9714
+ text += ` ${id}: ${status}${credits == null ? "" : ` (${String(credits)} credits)`}
9347
9715
  `;
9716
+ }
9717
+ }
9348
9718
  return {
9349
9719
  content: [{ type: "text", text }]
9350
9720
  };
@@ -14122,7 +14492,7 @@ function registerCarouselTools(server) {
14122
14492
  "Explicit hook/opener for slide 1. Overrides any hook derived from topic. Keep under 15 words for strongest scroll-stop."
14123
14493
  ),
14124
14494
  hook_family: z28.enum(["curiosity", "authority", "pain_point", "contrarian", "data_driven"]).optional().describe(
14125
- "Hook family tag. Recorded on the carousel so downstream analytics + bandit learners can attribute engagement to hook pattern."
14495
+ "Hook family tag. Recorded on the carousel so downstream analytics can attribute engagement to hook pattern."
14126
14496
  ),
14127
14497
  cta_text: z28.string().max(200).optional().describe("Explicit CTA copy for the final slide. If omitted, derived from topic."),
14128
14498
  cta_url: z28.string().url().optional().describe("URL promoted on the CTA slide. Defaults to the project landing page."),
@@ -14514,7 +14884,7 @@ function registerHyperframesTools(server) {
14514
14884
  );
14515
14885
  server.tool(
14516
14886
  "render_hyperframes",
14517
- "Render an HTML video composition (Hyperframes) to MP4. Author the composition as HTML with data-* timing attributes and GSAP timelines \u2014 frame-accurate, no React build step. Use list_hyperframes_blocks to see the pre-built block catalog. Returns a job ID \u2014 poll with check_status. Note: F4 v1 ships the EF + scaffold; the worker container needs the hyperframes runtime installed (Phase 2) before this returns finished MP4s.",
14887
+ "Render an HTML video composition (Hyperframes) to MP4. Author the composition as HTML with data-* timing attributes and GSAP timelines \u2014 frame-accurate, no React build step. Use list_hyperframes_blocks to see the pre-built block catalog. Pass project_id to keep the render with the correct brand/project. Returns a job ID \u2014 poll with check_status.",
14518
14888
  {
14519
14889
  composition_html: z30.string().max(5e5).optional().describe(
14520
14890
  "Inline HTML composition (full <html>...</html>). Max 500KB. Use composition_url for larger."
@@ -14526,7 +14896,8 @@ function registerHyperframesTools(server) {
14526
14896
  aspect_ratio: z30.enum(["9:16", "16:9", "1:1"]).optional().describe('Output aspect ratio. Default "9:16".'),
14527
14897
  duration_sec: z30.number().min(0.1).max(600).describe("Output duration in seconds. Required. Max 600 (10 min)."),
14528
14898
  fps: z30.union([z30.literal(24), z30.literal(30), z30.literal(60)]).optional().describe("Frames per second. Default 30."),
14529
- quality: z30.enum(["draft", "standard", "high"]).optional().describe("draft: fast iteration; standard: production; high: final master.")
14899
+ quality: z30.enum(["draft", "standard", "high"]).optional().describe("draft: fast iteration; standard: production; high: final master."),
14900
+ project_id: z30.string().optional().describe("Project ID to associate the Hyperframes render with.")
14530
14901
  },
14531
14902
  async ({
14532
14903
  composition_html,
@@ -14535,7 +14906,8 @@ function registerHyperframesTools(server) {
14535
14906
  aspect_ratio,
14536
14907
  duration_sec,
14537
14908
  fps,
14538
- quality
14909
+ quality,
14910
+ project_id
14539
14911
  }) => {
14540
14912
  const userId = await getDefaultUserId();
14541
14913
  const rateLimit = checkRateLimit("generation", `render_hyperframes:${userId}`);
@@ -14580,7 +14952,8 @@ function registerHyperframesTools(server) {
14580
14952
  aspectRatio: aspect_ratio || "9:16",
14581
14953
  durationSec: duration_sec,
14582
14954
  fps: fps || 30,
14583
- quality: quality || "standard"
14955
+ quality: quality || "standard",
14956
+ ...project_id && { projectId: project_id }
14584
14957
  });
14585
14958
  if (error || !data?.jobId) {
14586
14959
  throw new Error(error || data?.error || "Failed to create Hyperframes render job");
@@ -14596,7 +14969,7 @@ function registerHyperframesTools(server) {
14596
14969
  ` Duration: ${duration_sec}s @ ${fps || 30}fps (${aspect_ratio || "9:16"})`,
14597
14970
  ` Quality: ${quality || "standard"}`,
14598
14971
  ``,
14599
- `Poll with check_status. Note: until F4 Phase 2 wires the runtime, the job will fail with a clear "HyperframesNotInstalled" message.`
14972
+ `Poll with check_status.`
14600
14973
  ].join("\n")
14601
14974
  }
14602
14975
  ]
@@ -14869,7 +15242,10 @@ var init_content_calendar = __esm({
14869
15242
  import { z as z32 } from "zod";
14870
15243
  function findActiveAccount(accounts, platform3) {
14871
15244
  const target = platform3.toLowerCase();
14872
- return accounts.find((a) => a.platform.toLowerCase() === target && a.status === "active") ?? null;
15245
+ return accounts.find((a) => {
15246
+ const effectiveStatus = a.effective_status || a.status;
15247
+ return a.platform.toLowerCase() === target && (effectiveStatus === "active" || effectiveStatus === "expires_soon");
15248
+ }) ?? null;
14873
15249
  }
14874
15250
  function registerConnectionTools(server) {
14875
15251
  server.tool(
@@ -14877,11 +15253,15 @@ function registerConnectionTools(server) {
14877
15253
  "Begin connecting a social platform (Instagram, TikTok, YouTube, etc.). Returns a single-use deep link the user opens in a browser to complete the one-time OAuth handshake on socialneuron.com. This is NOT another OAuth in Claude \u2014 platform connections require a browser session because the social platforms (Meta, Google, TikTok) only accept callbacks on socialneuron.com. After the user clicks the link and approves on the platform, call `wait_for_connection` to detect completion. Link expires in 2 minutes; mint a new one if needed. Use `list_connected_accounts` first to check whether the platform is already connected before calling this.",
14878
15254
  {
14879
15255
  platform: z32.enum(PLATFORM_ENUM4).describe("Platform to connect. Lower-case: instagram, tiktok, youtube, etc."),
15256
+ project_id: z32.string().optional().describe(
15257
+ "Brand/project ID to bind the new social account to. Use this when connecting a brand-specific account."
15258
+ ),
14880
15259
  response_format: z32.enum(["text", "json"]).optional().describe("Response format. Default: text.")
14881
15260
  },
14882
- async ({ platform: platform3, response_format }) => {
15261
+ async ({ platform: platform3, project_id, response_format }) => {
14883
15262
  const format = response_format ?? "text";
14884
- const rl = checkRateLimit("read", `start_platform_connection:${platform3}`);
15263
+ const userId = await getDefaultUserId();
15264
+ const rl = checkRateLimit("read", `start_platform_connection:${userId}`);
14885
15265
  if (!rl.allowed) {
14886
15266
  return {
14887
15267
  content: [
@@ -14893,7 +15273,15 @@ function registerConnectionTools(server) {
14893
15273
  isError: true
14894
15274
  };
14895
15275
  }
14896
- const { data, error } = await callEdgeFunction("mcp-data", { action: "mint-connection-nonce", platform: platform3 }, { timeoutMs: 1e4 });
15276
+ const { data, error } = await callEdgeFunction(
15277
+ "mcp-data",
15278
+ {
15279
+ action: "mint-connection-nonce",
15280
+ platform: platform3,
15281
+ ...project_id ? { projectId: project_id, project_id } : {}
15282
+ },
15283
+ { timeoutMs: 1e4 }
15284
+ );
14897
15285
  if (error || !data?.success || !data.deep_link) {
14898
15286
  const errMsg = error ?? data?.error ?? "Unknown error";
14899
15287
  return {
@@ -14914,6 +15302,7 @@ function registerConnectionTools(server) {
14914
15302
  text: JSON.stringify(
14915
15303
  {
14916
15304
  platform: data.platform,
15305
+ project_id: project_id ?? null,
14917
15306
  deep_link: data.deep_link,
14918
15307
  expires_at: data.expires_at,
14919
15308
  next_step: "Open deep_link in a browser, approve on the platform, then call wait_for_connection."
@@ -14932,6 +15321,7 @@ function registerConnectionTools(server) {
14932
15321
  type: "text",
14933
15322
  text: [
14934
15323
  `${data.platform} connection ready.`,
15324
+ ...project_id ? [`Brand/project: ${project_id}`] : [],
14935
15325
  "",
14936
15326
  'Ask the user to open this link in a browser and click "Connect" on the platform:',
14937
15327
  ` ${data.deep_link}`,
@@ -14952,17 +15342,21 @@ function registerConnectionTools(server) {
14952
15342
  "Poll until a platform connection becomes active. Use after `start_platform_connection` while the user completes the browser OAuth flow. Returns when the account row appears with status=active, or when the timeout elapses. Default timeout 120s, max 600s.",
14953
15343
  {
14954
15344
  platform: z32.enum(PLATFORM_ENUM4).describe("Platform to wait for."),
15345
+ project_id: z32.string().optional().describe(
15346
+ "Brand/project ID to scope the connection poll. Use the same project_id passed to start_platform_connection."
15347
+ ),
14955
15348
  timeout_s: z32.number().min(5).max(600).optional().describe("How long to wait, in seconds. Default 120."),
14956
15349
  poll_interval_s: z32.number().min(2).max(30).optional().describe("Poll interval in seconds. Default 5."),
14957
15350
  response_format: z32.enum(["text", "json"]).optional().describe("Response format. Default: text.")
14958
15351
  },
14959
- async ({ platform: platform3, timeout_s, poll_interval_s, response_format }) => {
15352
+ async ({ platform: platform3, project_id, timeout_s, poll_interval_s, response_format }) => {
14960
15353
  const format = response_format ?? "text";
14961
15354
  const startedAt = Date.now();
14962
15355
  const timeoutMs = (timeout_s ?? 120) * 1e3;
14963
15356
  const intervalMs = (poll_interval_s ?? 5) * 1e3;
14964
15357
  const deadline = startedAt + timeoutMs;
14965
- const rl = checkRateLimit("read", `wait_for_connection:${platform3}`);
15358
+ const userId = await getDefaultUserId();
15359
+ const rl = checkRateLimit("read", `wait_for_connection:${userId}`);
14966
15360
  if (!rl.allowed) {
14967
15361
  return {
14968
15362
  content: [
@@ -14974,90 +15368,123 @@ function registerConnectionTools(server) {
14974
15368
  isError: true
14975
15369
  };
14976
15370
  }
14977
- let attempts = 0;
14978
- while (Date.now() < deadline) {
14979
- attempts++;
14980
- const { data, error } = await callEdgeFunction("mcp-data", { action: "connected-accounts" }, { timeoutMs: 1e4 });
14981
- if (!error && data?.success) {
14982
- const found = findActiveAccount(data.accounts ?? [], platform3);
14983
- if (found) {
14984
- if (format === "json") {
15371
+ const activeWaits = inFlightWaitsByUser.get(userId) ?? 0;
15372
+ if (activeWaits >= MAX_CONCURRENT_WAITS_PER_USER) {
15373
+ return {
15374
+ content: [
15375
+ {
15376
+ type: "text",
15377
+ text: `Too many concurrent \`wait_for_connection\` calls (max ${MAX_CONCURRENT_WAITS_PER_USER}). Let an existing wait finish, or poll \`list_connected_accounts\` instead.`
15378
+ }
15379
+ ],
15380
+ isError: true
15381
+ };
15382
+ }
15383
+ inFlightWaitsByUser.set(userId, activeWaits + 1);
15384
+ try {
15385
+ let attempts = 0;
15386
+ while (Date.now() < deadline) {
15387
+ attempts++;
15388
+ const { data, error } = await callEdgeFunction(
15389
+ "mcp-data",
15390
+ {
15391
+ action: "connected-accounts",
15392
+ ...project_id ? { projectId: project_id, project_id } : {}
15393
+ },
15394
+ { timeoutMs: 1e4 }
15395
+ );
15396
+ if (!error && data?.success) {
15397
+ const found = findActiveAccount(data.accounts ?? [], platform3);
15398
+ if (found) {
15399
+ if (format === "json") {
15400
+ return {
15401
+ content: [
15402
+ {
15403
+ type: "text",
15404
+ text: JSON.stringify(
15405
+ {
15406
+ connected: true,
15407
+ platform: found.platform,
15408
+ project_id: found.project_id ?? project_id ?? null,
15409
+ account_id: found.id,
15410
+ username: found.username,
15411
+ connected_at: found.created_at,
15412
+ attempts
15413
+ },
15414
+ null,
15415
+ 2
15416
+ )
15417
+ }
15418
+ ],
15419
+ isError: false
15420
+ };
15421
+ }
14985
15422
  return {
14986
15423
  content: [
14987
15424
  {
14988
15425
  type: "text",
14989
- text: JSON.stringify(
14990
- {
14991
- connected: true,
14992
- platform: found.platform,
14993
- account_id: found.id,
14994
- username: found.username,
14995
- connected_at: found.created_at,
14996
- attempts
14997
- },
14998
- null,
14999
- 2
15000
- )
15426
+ text: [
15427
+ `${found.platform} is connected.`,
15428
+ ...found.project_id || project_id ? [`Brand/project: ${found.project_id ?? project_id}`] : [],
15429
+ `Account: ${found.username || "(unnamed)"} (id=${found.id})`,
15430
+ `Detected after ${attempts} poll(s) in ${((Date.now() - startedAt) / 1e3).toFixed(1)}s.`,
15431
+ "Ready to call `schedule_post`."
15432
+ ].join("\n")
15001
15433
  }
15002
15434
  ],
15003
15435
  isError: false
15004
15436
  };
15005
15437
  }
15006
- return {
15007
- content: [
15008
- {
15009
- type: "text",
15010
- text: [
15011
- `${found.platform} is connected.`,
15012
- `Account: ${found.username || "(unnamed)"} (id=${found.id})`,
15013
- `Detected after ${attempts} poll(s) in ${((Date.now() - startedAt) / 1e3).toFixed(1)}s.`,
15014
- "Ready to call `schedule_post`."
15015
- ].join("\n")
15016
- }
15017
- ],
15018
- isError: false
15019
- };
15020
15438
  }
15439
+ const remaining = deadline - Date.now();
15440
+ if (remaining <= 0) break;
15441
+ await new Promise((resolve3) => setTimeout(resolve3, Math.min(intervalMs, remaining)));
15442
+ }
15443
+ const message = `${platform3} did not connect within ${timeout_s ?? 120}s (${attempts} polls). The user may not have completed the browser OAuth yet, or the link expired. Mint a new link with \`start_platform_connection\` and try again, or have the user go directly to socialneuron.com/settings/connections.`;
15444
+ if (format === "json") {
15445
+ return {
15446
+ content: [
15447
+ {
15448
+ type: "text",
15449
+ text: JSON.stringify(
15450
+ {
15451
+ connected: false,
15452
+ platform: platform3,
15453
+ project_id: project_id ?? null,
15454
+ attempts,
15455
+ timed_out: true,
15456
+ message
15457
+ },
15458
+ null,
15459
+ 2
15460
+ )
15461
+ }
15462
+ ],
15463
+ isError: true
15464
+ };
15021
15465
  }
15022
- const remaining = deadline - Date.now();
15023
- if (remaining <= 0) break;
15024
- await new Promise((resolve3) => setTimeout(resolve3, Math.min(intervalMs, remaining)));
15025
- }
15026
- const message = `${platform3} did not connect within ${timeout_s ?? 120}s (${attempts} polls). The user may not have completed the browser OAuth yet, or the link expired. Mint a new link with \`start_platform_connection\` and try again, or have the user go directly to socialneuron.com/settings/connections.`;
15027
- if (format === "json") {
15028
15466
  return {
15029
- content: [
15030
- {
15031
- type: "text",
15032
- text: JSON.stringify(
15033
- {
15034
- connected: false,
15035
- platform: platform3,
15036
- attempts,
15037
- timed_out: true,
15038
- message
15039
- },
15040
- null,
15041
- 2
15042
- )
15043
- }
15044
- ],
15467
+ content: [{ type: "text", text: message }],
15045
15468
  isError: true
15046
15469
  };
15470
+ } finally {
15471
+ const remainingWaits = (inFlightWaitsByUser.get(userId) ?? 1) - 1;
15472
+ if (remainingWaits <= 0) {
15473
+ inFlightWaitsByUser.delete(userId);
15474
+ } else {
15475
+ inFlightWaitsByUser.set(userId, remainingWaits);
15476
+ }
15047
15477
  }
15048
- return {
15049
- content: [{ type: "text", text: message }],
15050
- isError: true
15051
- };
15052
15478
  }
15053
15479
  );
15054
15480
  }
15055
- var PLATFORM_ENUM4;
15481
+ var PLATFORM_ENUM4, MAX_CONCURRENT_WAITS_PER_USER, inFlightWaitsByUser;
15056
15482
  var init_connections = __esm({
15057
15483
  "src/tools/connections.ts"() {
15058
15484
  "use strict";
15059
15485
  init_edge_function();
15060
15486
  init_rate_limit();
15487
+ init_supabase();
15061
15488
  PLATFORM_ENUM4 = [
15062
15489
  "youtube",
15063
15490
  "tiktok",
@@ -15070,6 +15497,8 @@ var init_connections = __esm({
15070
15497
  "shopify",
15071
15498
  "etsy"
15072
15499
  ];
15500
+ MAX_CONCURRENT_WAITS_PER_USER = 3;
15501
+ inFlightWaitsByUser = /* @__PURE__ */ new Map();
15073
15502
  }
15074
15503
  });
15075
15504
 
@@ -15934,7 +16363,12 @@ function applyScopeEnforcement(server, scopeResolver) {
15934
16363
  toolName: name,
15935
16364
  status,
15936
16365
  durationMs: Date.now() - startedAt,
15937
- details: { source: "wrapper" }
16366
+ details: {
16367
+ source: "wrapper",
16368
+ // Classify failures so the tool-error rate is diagnosable
16369
+ // (validation vs permission vs billing vs upstream vs server).
16370
+ ...status === "error" ? { error_type: classifyToolError(result) } : {}
16371
+ }
15938
16372
  });
15939
16373
  return truncateResponse(result);
15940
16374
  } catch (err) {
@@ -15944,6 +16378,8 @@ function applyScopeEnforcement(server, scopeResolver) {
15944
16378
  durationMs: Date.now() - startedAt,
15945
16379
  details: {
15946
16380
  source: "wrapper",
16381
+ // A thrown exception escaped the handler — an unclassified fault.
16382
+ error_type: "server_error",
15947
16383
  exception: err instanceof Error ? err.message.slice(0, 200) : "unknown"
15948
16384
  }
15949
16385
  });
@@ -16037,6 +16473,7 @@ function truncateResponse(result) {
16037
16473
  return { ...result, content: truncated };
16038
16474
  }
16039
16475
  function registerAllTools(server, options) {
16476
+ applyToolProfile(server, options?.toolProfile ?? "full");
16040
16477
  registerIdeationTools(server);
16041
16478
  registerContentTools(server);
16042
16479
  registerDistributionTools(server);
@@ -16089,6 +16526,7 @@ var init_register_tools = __esm({
16089
16526
  init_supabase();
16090
16527
  init_www_authenticate();
16091
16528
  init_tool_error();
16529
+ init_tool_profile();
16092
16530
  init_scanner();
16093
16531
  init_ideation();
16094
16532
  init_content2();
@@ -16173,13 +16611,13 @@ async function invokeToolRest(name, args) {
16173
16611
  }
16174
16612
  function extractRestError(result) {
16175
16613
  const sc = result.structuredContent?.error;
16176
- if (sc?.error_type && KNOWN_ERROR_TYPES.has(sc.error_type)) {
16614
+ if (sc?.error_type && KNOWN_ERROR_TYPES2.has(sc.error_type)) {
16177
16615
  return { error_type: sc.error_type, message: sc.message ?? "Tool error." };
16178
16616
  }
16179
16617
  const text = result.content?.find((c) => c.type === "text")?.text ?? "";
16180
16618
  try {
16181
16619
  const parsed = JSON.parse(text);
16182
- if (parsed.error_type && KNOWN_ERROR_TYPES.has(parsed.error_type)) {
16620
+ if (parsed.error_type && KNOWN_ERROR_TYPES2.has(parsed.error_type)) {
16183
16621
  return { error_type: parsed.error_type, message: parsed.message ?? text };
16184
16622
  }
16185
16623
  } catch {
@@ -16189,7 +16627,7 @@ function extractRestError(result) {
16189
16627
  }
16190
16628
  return { error_type: "server_error", message: text || "Tool error." };
16191
16629
  }
16192
- var cachedCallHandler, KNOWN_ERROR_TYPES;
16630
+ var cachedCallHandler, KNOWN_ERROR_TYPES2;
16193
16631
  var init_rest_invoke = __esm({
16194
16632
  "src/lib/rest-invoke.ts"() {
16195
16633
  "use strict";
@@ -16197,7 +16635,7 @@ var init_rest_invoke = __esm({
16197
16635
  init_request_context();
16198
16636
  init_version();
16199
16637
  init_tool_catalog();
16200
- KNOWN_ERROR_TYPES = /* @__PURE__ */ new Set([
16638
+ KNOWN_ERROR_TYPES2 = /* @__PURE__ */ new Set([
16201
16639
  "policy_block",
16202
16640
  "validation_error",
16203
16641
  "permission_denied",
@@ -16274,7 +16712,9 @@ async function handleCall(args, asJson) {
16274
16712
  scopes: auth.scopes ?? [],
16275
16713
  token: apiKey,
16276
16714
  creditsUsed: 0,
16277
- assetsGenerated: 0
16715
+ assetsGenerated: 0,
16716
+ surface: "cli",
16717
+ projectId: auth.projectId ?? null
16278
16718
  },
16279
16719
  () => invokeToolRest(toolName, toolArgs)
16280
16720
  );