@socialneuron/mcp-server 1.7.14 → 1.7.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/http.js CHANGED
@@ -420,8 +420,8 @@ async function getDefaultUserId() {
420
420
  async function getDefaultProjectId() {
421
421
  const userId = await getDefaultUserId().catch(() => null);
422
422
  if (userId) {
423
- const cached2 = projectIdCache.get(userId);
424
- if (cached2) return cached2;
423
+ const cached3 = projectIdCache.get(userId);
424
+ if (cached3) return cached3;
425
425
  }
426
426
  const envProjectId = process.env.SOCIALNEURON_PROJECT_ID;
427
427
  if (envProjectId) {
@@ -568,7 +568,7 @@ var init_supabase = __esm({
568
568
  // src/http.ts
569
569
  import express from "express";
570
570
  import { randomUUID as randomUUID4 } from "node:crypto";
571
- import { McpServer as McpServer2 } from "@modelcontextprotocol/sdk/server/mcp.js";
571
+ import { McpServer as McpServer3 } from "@modelcontextprotocol/sdk/server/mcp.js";
572
572
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
573
573
  import {
574
574
  mcpAuthRouter,
@@ -620,6 +620,7 @@ var TOOL_SCOPES = {
620
620
  adapt_content: "mcp:write",
621
621
  generate_video: "mcp:write",
622
622
  generate_image: "mcp:write",
623
+ // check_status is read-only (polls job state) despite living in this write block.
623
624
  check_status: "mcp:read",
624
625
  render_demo_video: "mcp:write",
625
626
  render_template_video: "mcp:write",
@@ -707,10 +708,10 @@ var TOOL_SCOPES = {
707
708
  record_campaign_spend: "mcp:write",
708
709
  // mcp:read
709
710
  get_active_campaigns: "mcp:read",
710
- // mcp:read / mcp:write (Skills — PR #4.4)
711
+ // mcp:read / mcp:write (Skills)
711
712
  list_skills: "mcp:read",
712
713
  run_skill: "mcp:write",
713
- // mcp:read (Loop observability — growth-loop KPIs + bandit posteriors)
714
+ // mcp:read (Loop observability — growth-loop KPIs + content learning state)
714
715
  get_loop_pulse: "mcp:read",
715
716
  get_bandit_state: "mcp:read"
716
717
  };
@@ -891,6 +892,24 @@ function buildWwwAuthenticateHeader(opts) {
891
892
  return `Bearer ${params.join(", ")}`;
892
893
  }
893
894
 
895
+ // src/lib/tool-error.ts
896
+ function toolError(code, message, opts = {}) {
897
+ const errorObj = {
898
+ error_type: code,
899
+ message,
900
+ ...opts.recover_with && opts.recover_with.length ? { recover_with: opts.recover_with } : {},
901
+ ...opts.details ?? {}
902
+ };
903
+ return {
904
+ // Text mirror: models read the JSON; `error_type` is the first line so it
905
+ // is cheap to grep from the text block even without structuredContent support.
906
+ content: [{ type: "text", text: JSON.stringify(errorObj, null, 2) }],
907
+ structuredContent: { error: errorObj },
908
+ isError: true,
909
+ ...opts.meta ? { _meta: opts.meta } : {}
910
+ };
911
+ }
912
+
894
913
  // src/lib/agent-harness/constants.json
895
914
  var constants_default = {
896
915
  ZERO_WIDTH_CHARS: "[\\u200B\\u200C\\u200D\\u2060\\uFEFF\\u{E0020}-\\u{E007F}]",
@@ -1144,11 +1163,15 @@ async function callEdgeFunction(functionName, body, options) {
1144
1163
  // src/lib/rate-limit.ts
1145
1164
  var CATEGORY_CONFIGS = {
1146
1165
  posting: { maxTokens: 30, refillRate: 30 / 60 },
1147
- // 30 req/min
1166
+ // 30 req/min — publish/schedule/comment
1167
+ generation: { maxTokens: 15, refillRate: 15 / 60 },
1168
+ // 15 req/min — expensive AI media gen
1169
+ upload: { maxTokens: 20, refillRate: 20 / 60 },
1170
+ // 20 req/min — media upload
1148
1171
  screenshot: { maxTokens: 10, refillRate: 10 / 60 },
1149
- // 10 req/min
1172
+ // 10 req/min — browser capture
1150
1173
  read: { maxTokens: 60, refillRate: 60 / 60 }
1151
- // 60 req/min
1174
+ // 60 req/min — default
1152
1175
  };
1153
1176
  var RateLimiter = class {
1154
1177
  tokens;
@@ -1190,18 +1213,19 @@ var RateLimiter = class {
1190
1213
  }
1191
1214
  };
1192
1215
  var limiters = /* @__PURE__ */ new Map();
1193
- function getRateLimiter(category) {
1194
- let limiter = limiters.get(category);
1216
+ function getRateLimiter(bucketKey, category) {
1217
+ let limiter = limiters.get(bucketKey);
1195
1218
  if (!limiter) {
1196
- const config = CATEGORY_CONFIGS[category] ?? CATEGORY_CONFIGS.read;
1219
+ const resolved = category ?? bucketKey;
1220
+ const config = CATEGORY_CONFIGS[resolved] ?? CATEGORY_CONFIGS.read;
1197
1221
  limiter = new RateLimiter(config);
1198
- limiters.set(category, limiter);
1222
+ limiters.set(bucketKey, limiter);
1199
1223
  }
1200
1224
  return limiter;
1201
1225
  }
1202
1226
  function checkRateLimit(category, key) {
1203
1227
  const bucketKey = key ? `${category}:${key}` : category;
1204
- const limiter = getRateLimiter(bucketKey);
1228
+ const limiter = getRateLimiter(bucketKey, category);
1205
1229
  const allowed = limiter.consume();
1206
1230
  return {
1207
1231
  allowed,
@@ -1213,7 +1237,7 @@ function checkRateLimit(category, key) {
1213
1237
  init_supabase();
1214
1238
 
1215
1239
  // src/lib/version.ts
1216
- var MCP_VERSION = "1.7.14";
1240
+ var MCP_VERSION = "1.7.18";
1217
1241
 
1218
1242
  // src/tools/ideation.ts
1219
1243
  function asEnvelope(data) {
@@ -2913,7 +2937,11 @@ async function rehostExternalUrl(mediaUrl, projectId) {
2913
2937
  if (isAlreadyR2Signed(mediaUrl)) {
2914
2938
  return { signedUrl: mediaUrl, r2Key: "" };
2915
2939
  }
2916
- const { data, error } = await callEdgeFunction("upload-to-r2", { url: ssrf.sanitizedUrl ?? mediaUrl, projectId }, { timeoutMs: 6e4 });
2940
+ const { data, error } = await callEdgeFunction(
2941
+ "upload-to-r2",
2942
+ { url: ssrf.sanitizedUrl ?? mediaUrl, projectId },
2943
+ { timeoutMs: 6e4 }
2944
+ );
2917
2945
  if (error || !data?.key || !data?.url) {
2918
2946
  return { error: error ?? "upload-to-r2 returned no key" };
2919
2947
  }
@@ -2949,14 +2977,18 @@ function registerDistributionTools(server) {
2949
2977
  "MUTUAL_FOLLOW_FRIENDS",
2950
2978
  "FOLLOWER_OF_CREATOR",
2951
2979
  "SELF_ONLY"
2952
- ]).optional().describe("Required unless useInbox=true. Who can view the video."),
2980
+ ]).optional().describe(
2981
+ "Required unless useInbox=true. Who can view the video."
2982
+ ),
2953
2983
  enable_duet: z3.boolean().optional(),
2954
2984
  enable_comment: z3.boolean().optional(),
2955
2985
  enable_stitch: z3.boolean().optional(),
2956
2986
  is_ai_generated: z3.boolean().optional(),
2957
2987
  brand_content: z3.boolean().optional(),
2958
2988
  brand_organic: z3.boolean().optional(),
2959
- use_inbox: z3.boolean().optional().describe("Post to TikTok inbox/draft instead of direct publish.")
2989
+ use_inbox: z3.boolean().optional().describe(
2990
+ "Post to TikTok inbox/draft instead of direct publish."
2991
+ )
2960
2992
  }).optional(),
2961
2993
  youtube: z3.object({
2962
2994
  title: z3.string().optional().describe("Video title (required for YouTube)."),
@@ -3034,7 +3066,9 @@ function registerDistributionTools(server) {
3034
3066
  ),
3035
3067
  project_id: z3.string().optional().describe("Social Neuron project ID to associate this post with."),
3036
3068
  response_format: z3.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text."),
3037
- attribution: z3.boolean().optional().describe('If true, appends "Created with Social Neuron" to the caption. Default: false.'),
3069
+ attribution: z3.boolean().optional().describe(
3070
+ 'If true, appends "Created with Social Neuron" to the caption. Default: false.'
3071
+ ),
3038
3072
  account_id: z3.string().optional().describe(
3039
3073
  "Connected account ID to post from. Use list_connected_accounts to find the right ID. Required when multiple accounts exist for the same platform."
3040
3074
  ),
@@ -3048,10 +3082,10 @@ function registerDistributionTools(server) {
3048
3082
  "Visual QA gate verdict from the carousel/image generation pipeline. Required when mediaType is CAROUSEL_ALBUM, IMAGE, or VIDEO and VISUAL_GATE_ENFORCE is enabled on the EF. Produce with visual_quality_check tool. Must have passed=true for the gate to allow the publish."
3049
3083
  ),
3050
3084
  origin: z3.enum(["human", "hermes", "user"]).optional().describe(
3051
- "Originator lineage for the post. 'hermes' marks the post as scheduled by the Hermes autonomous agent \u2014 surfaces the Hermes badge + filter in Distribution. Default 'human'. Use only when calling from an autonomous workflow (cron, Slack bot, etc.)."
3085
+ "Originator lineage for the post. Marks who scheduled it; use the agent value when calling from an autonomous workflow, otherwise default 'human'."
3052
3086
  ),
3053
3087
  hermes_run_id: z3.string().optional().describe(
3054
- "Hermes cron run identifier \u2014 e.g. 'cron_<id>_<timestamp>'. Persists to posts.hermes_run_id when origin='hermes'. Use for traceability between drafts and the cron that produced them."
3088
+ "Optional agent run identifier for traceability between a draft and the run that produced it."
3055
3089
  )
3056
3090
  },
3057
3091
  async ({
@@ -3081,7 +3115,12 @@ function registerDistributionTools(server) {
3081
3115
  const format = response_format ?? "text";
3082
3116
  if ((!caption || caption.trim().length === 0) && (!title || title.trim().length === 0)) {
3083
3117
  return {
3084
- content: [{ type: "text", text: "Either caption or title is required." }],
3118
+ content: [
3119
+ {
3120
+ type: "text",
3121
+ text: "Either caption or title is required."
3122
+ }
3123
+ ],
3085
3124
  isError: true
3086
3125
  };
3087
3126
  }
@@ -3110,7 +3149,11 @@ function registerDistributionTools(server) {
3110
3149
  return signData?.signedUrl ?? signData?.url ?? null;
3111
3150
  };
3112
3151
  const resolveJobId = async (jid) => {
3113
- const { data: jobData } = await callEdgeFunction("mcp-data", { action: "job-status", jobId: jid }, { timeoutMs: 1e4 });
3152
+ const { data: jobData } = await callEdgeFunction(
3153
+ "mcp-data",
3154
+ { action: "job-status", jobId: jid },
3155
+ { timeoutMs: 1e4 }
3156
+ );
3114
3157
  const resultUrl = jobData?.job?.result_url;
3115
3158
  if (!resultUrl) return null;
3116
3159
  if (!resultUrl.startsWith("http")) return signR2Key(resultUrl);
@@ -3210,9 +3253,7 @@ function registerDistributionTools(server) {
3210
3253
  isError: true
3211
3254
  };
3212
3255
  }
3213
- resolvedMediaUrls = rehosted.map(
3214
- (r) => r.signedUrl
3215
- );
3256
+ resolvedMediaUrls = rehosted.map((r) => r.signedUrl);
3216
3257
  }
3217
3258
  } catch (resolveErr) {
3218
3259
  return {
@@ -3225,8 +3266,12 @@ function registerDistributionTools(server) {
3225
3266
  isError: true
3226
3267
  };
3227
3268
  }
3228
- const normalizedPlatforms = platforms.map((p) => PLATFORM_CASE_MAP[p.toLowerCase()] || p);
3229
- const blockedPlatforms = normalizedPlatforms.filter((p) => MCP_NOT_LIVE_FOR_POSTING.has(p));
3269
+ const normalizedPlatforms = platforms.map(
3270
+ (p) => PLATFORM_CASE_MAP[p.toLowerCase()] || p
3271
+ );
3272
+ const blockedPlatforms = normalizedPlatforms.filter(
3273
+ (p) => MCP_NOT_LIVE_FOR_POSTING.has(p)
3274
+ );
3230
3275
  if (blockedPlatforms.length > 0) {
3231
3276
  return {
3232
3277
  content: [
@@ -3329,7 +3374,9 @@ Created with Social Neuron`;
3329
3374
  projectId: project_id,
3330
3375
  ...connectedAccountIds ? { connectedAccountIds } : {},
3331
3376
  ...normalizedPlatformMetadata ? {
3332
- platformMetadata: convertPlatformMetadata(normalizedPlatformMetadata)
3377
+ platformMetadata: convertPlatformMetadata(
3378
+ normalizedPlatformMetadata
3379
+ )
3333
3380
  } : {},
3334
3381
  // Forward visual gate verdict + source so the EF can enforce on media posts.
3335
3382
  ...visual_gate_result ? { visualGateResult: visual_gate_result } : {},
@@ -3377,7 +3424,9 @@ Created with Social Neuron`;
3377
3424
  lines.push("", "Platform results:");
3378
3425
  for (const [platform2, result] of Object.entries(data.results)) {
3379
3426
  if (result.success) {
3380
- lines.push(` ${platform2}: OK (jobId=${result.jobId}, postId=${result.postId})`);
3427
+ lines.push(
3428
+ ` ${platform2}: OK (jobId=${result.jobId}, postId=${result.postId})`
3429
+ );
3381
3430
  } else {
3382
3431
  lines.push(` ${platform2}: FAILED - ${result.error}`);
3383
3432
  }
@@ -3386,7 +3435,12 @@ Created with Social Neuron`;
3386
3435
  const structuredContent = asEnvelope3(data);
3387
3436
  return {
3388
3437
  structuredContent,
3389
- content: [{ type: "text", text: JSON.stringify(structuredContent, null, 2) }],
3438
+ content: [
3439
+ {
3440
+ type: "text",
3441
+ text: JSON.stringify(structuredContent, null, 2)
3442
+ }
3443
+ ],
3390
3444
  isError: !data.success
3391
3445
  };
3392
3446
  }
@@ -3442,12 +3496,17 @@ Created with Social Neuron`;
3442
3496
  for (const account of accounts) {
3443
3497
  const name = account.username || "(unnamed)";
3444
3498
  const platformLower = account.platform.toLowerCase();
3445
- lines.push(` ${platformLower}: ${name} (connected ${account.created_at.split("T")[0]})`);
3499
+ lines.push(
3500
+ ` ${platformLower}: ${name} (connected ${account.created_at.split("T")[0]})`
3501
+ );
3446
3502
  }
3447
3503
  if (format === "json") {
3448
3504
  return {
3449
3505
  content: [
3450
- { type: "text", text: JSON.stringify(asEnvelope3({ accounts }), null, 2) }
3506
+ {
3507
+ type: "text",
3508
+ text: JSON.stringify(asEnvelope3({ accounts }), null, 2)
3509
+ }
3451
3510
  ]
3452
3511
  };
3453
3512
  }
@@ -3502,7 +3561,12 @@ Created with Social Neuron`;
3502
3561
  const structuredContent2 = asEnvelope3({ posts: [] });
3503
3562
  return {
3504
3563
  structuredContent: structuredContent2,
3505
- content: [{ type: "text", text: JSON.stringify(structuredContent2, null, 2) }]
3564
+ content: [
3565
+ {
3566
+ type: "text",
3567
+ text: JSON.stringify(structuredContent2, null, 2)
3568
+ }
3569
+ ]
3506
3570
  };
3507
3571
  }
3508
3572
  return {
@@ -3519,7 +3583,12 @@ Created with Social Neuron`;
3519
3583
  if (format === "json") {
3520
3584
  return {
3521
3585
  structuredContent,
3522
- content: [{ type: "text", text: JSON.stringify(structuredContent, null, 2) }]
3586
+ content: [
3587
+ {
3588
+ type: "text",
3589
+ text: JSON.stringify(structuredContent, null, 2)
3590
+ }
3591
+ ]
3523
3592
  };
3524
3593
  }
3525
3594
  const statusIcon = {
@@ -3580,7 +3649,13 @@ Created with Social Neuron`;
3580
3649
  min_gap_hours: z3.number().min(1).max(24).default(4).describe("Minimum gap between posts on same platform"),
3581
3650
  response_format: z3.enum(["text", "json"]).default("text")
3582
3651
  },
3583
- async ({ platforms, count, start_after, min_gap_hours, response_format }) => {
3652
+ async ({
3653
+ platforms,
3654
+ count,
3655
+ start_after,
3656
+ min_gap_hours,
3657
+ response_format
3658
+ }) => {
3584
3659
  try {
3585
3660
  const startDate = start_after ? new Date(start_after) : /* @__PURE__ */ new Date();
3586
3661
  const endDate = new Date(startDate.getTime() + 7 * 24 * 60 * 60 * 1e3);
@@ -3594,7 +3669,9 @@ Created with Social Neuron`;
3594
3669
  const gapMs = min_gap_hours * 60 * 60 * 1e3;
3595
3670
  const candidates = [];
3596
3671
  for (let dayOffset = 0; dayOffset < 7; dayOffset++) {
3597
- const date = new Date(startDate.getTime() + dayOffset * 24 * 60 * 60 * 1e3);
3672
+ const date = new Date(
3673
+ startDate.getTime() + dayOffset * 24 * 60 * 60 * 1e3
3674
+ );
3598
3675
  const dayOfWeek = date.getUTCDay();
3599
3676
  for (const platform2 of platforms) {
3600
3677
  const hours = PREFERRED_HOURS[platform2] ?? [12, 16];
@@ -3603,8 +3680,11 @@ Created with Social Neuron`;
3603
3680
  slotDate.setUTCHours(hours[hourIdx], 0, 0, 0);
3604
3681
  if (slotDate <= startDate) continue;
3605
3682
  const hasConflict = (existingPosts ?? []).some((post) => {
3606
- if (String(post.platform).toLowerCase() !== platform2) return false;
3607
- const postTime = new Date(post.scheduled_at ?? post.published_at).getTime();
3683
+ if (String(post.platform).toLowerCase() !== platform2)
3684
+ return false;
3685
+ const postTime = new Date(
3686
+ post.scheduled_at ?? post.published_at
3687
+ ).getTime();
3608
3688
  return Math.abs(postTime - slotDate.getTime()) < gapMs;
3609
3689
  });
3610
3690
  let engagementScore = hours.length - hourIdx;
@@ -3642,19 +3722,28 @@ Created with Social Neuron`;
3642
3722
  };
3643
3723
  }
3644
3724
  const lines = [];
3645
- lines.push(`Found ${slots.length} optimal slots (${conflictsAvoided} conflicts avoided):`);
3725
+ lines.push(
3726
+ `Found ${slots.length} optimal slots (${conflictsAvoided} conflicts avoided):`
3727
+ );
3646
3728
  lines.push("");
3647
3729
  lines.push("Datetime (UTC) | Platform | Score");
3648
3730
  lines.push("-------------------------+------------+------");
3649
3731
  for (const s of slots) {
3650
3732
  const dt = s.datetime.replace("T", " ").slice(0, 19);
3651
- lines.push(`${dt.padEnd(25)}| ${s.platform.padEnd(11)}| ${s.engagement_score}`);
3733
+ lines.push(
3734
+ `${dt.padEnd(25)}| ${s.platform.padEnd(11)}| ${s.engagement_score}`
3735
+ );
3652
3736
  }
3653
- return { content: [{ type: "text", text: lines.join("\n") }], isError: false };
3737
+ return {
3738
+ content: [{ type: "text", text: lines.join("\n") }],
3739
+ isError: false
3740
+ };
3654
3741
  } catch (err) {
3655
3742
  const message = sanitizeError(err);
3656
3743
  return {
3657
- content: [{ type: "text", text: `Failed to find slots: ${message}` }],
3744
+ content: [
3745
+ { type: "text", text: `Failed to find slots: ${message}` }
3746
+ ],
3658
3747
  isError: true
3659
3748
  };
3660
3749
  }
@@ -3681,8 +3770,12 @@ Created with Social Neuron`;
3681
3770
  auto_slot: z3.boolean().default(true).describe("Auto-assign time slots for posts without schedule_at"),
3682
3771
  dry_run: z3.boolean().default(false).describe("Preview without actually scheduling"),
3683
3772
  response_format: z3.enum(["text", "json"]).default("text"),
3684
- enforce_quality: z3.boolean().default(true).describe("When true, block scheduling for posts that fail quality checks."),
3685
- quality_threshold: z3.number().int().min(0).max(35).optional().describe("Optional quality threshold override. Defaults to project setting or 26."),
3773
+ enforce_quality: z3.boolean().default(true).describe(
3774
+ "When true, block scheduling for posts that fail quality checks."
3775
+ ),
3776
+ quality_threshold: z3.number().int().min(0).max(35).optional().describe(
3777
+ "Optional quality threshold override. Defaults to project setting or 26."
3778
+ ),
3686
3779
  batch_size: z3.number().int().min(1).max(10).default(4).describe("Concurrent schedule calls per platform batch."),
3687
3780
  idempotency_seed: z3.string().max(128).optional().describe("Optional stable seed used when building idempotency keys.")
3688
3781
  },
@@ -3719,17 +3812,25 @@ Created with Social Neuron`;
3719
3812
  if (!stored?.plan_payload) {
3720
3813
  return {
3721
3814
  content: [
3722
- { type: "text", text: `No content plan found for plan_id=${plan_id}` }
3815
+ {
3816
+ type: "text",
3817
+ text: `No content plan found for plan_id=${plan_id}`
3818
+ }
3723
3819
  ],
3724
3820
  isError: true
3725
3821
  };
3726
3822
  }
3727
3823
  const payload = stored.plan_payload;
3728
- const postsFromPayload = Array.isArray(payload.posts) ? payload.posts : Array.isArray(payload.data?.posts) ? payload.data.posts : null;
3824
+ const postsFromPayload = Array.isArray(payload.posts) ? payload.posts : Array.isArray(
3825
+ payload.data?.posts
3826
+ ) ? payload.data.posts : null;
3729
3827
  if (!postsFromPayload) {
3730
3828
  return {
3731
3829
  content: [
3732
- { type: "text", text: `Stored plan ${plan_id} has no posts array.` }
3830
+ {
3831
+ type: "text",
3832
+ text: `Stored plan ${plan_id} has no posts array.`
3833
+ }
3733
3834
  ],
3734
3835
  isError: true
3735
3836
  };
@@ -3759,7 +3860,10 @@ Created with Social Neuron`;
3759
3860
  }
3760
3861
  }
3761
3862
  if (effectivePlanId) {
3762
- const { data: approvalsResult, error: approvalsError } = await callEdgeFunction("mcp-data", { action: "list-plan-approvals", plan_id: effectivePlanId });
3863
+ const { data: approvalsResult, error: approvalsError } = await callEdgeFunction("mcp-data", {
3864
+ action: "list-plan-approvals",
3865
+ plan_id: effectivePlanId
3866
+ });
3763
3867
  if (approvalsError) {
3764
3868
  return {
3765
3869
  content: [
@@ -3820,7 +3924,10 @@ Created with Social Neuron`;
3820
3924
  approvalSummary = {
3821
3925
  total: approvals.length,
3822
3926
  eligible: approvedPosts.length,
3823
- skipped: Math.max(0, workingPlan.posts.length - approvedPosts.length)
3927
+ skipped: Math.max(
3928
+ 0,
3929
+ workingPlan.posts.length - approvedPosts.length
3930
+ )
3824
3931
  };
3825
3932
  workingPlan = {
3826
3933
  ...workingPlan,
@@ -3890,13 +3997,18 @@ Created with Social Neuron`;
3890
3997
  }
3891
3998
  };
3892
3999
  });
3893
- const qualityPassed = postsWithResults.filter((post) => post.quality.passed).length;
4000
+ const qualityPassed = postsWithResults.filter(
4001
+ (post) => post.quality.passed
4002
+ ).length;
3894
4003
  const qualitySummary = {
3895
4004
  total_posts: postsWithResults.length,
3896
4005
  passed: qualityPassed,
3897
4006
  failed: postsWithResults.length - qualityPassed,
3898
4007
  avg_score: postsWithResults.length > 0 ? Number(
3899
- (postsWithResults.reduce((sum, post) => sum + post.quality.score, 0) / postsWithResults.length).toFixed(2)
4008
+ (postsWithResults.reduce(
4009
+ (sum, post) => sum + post.quality.score,
4010
+ 0
4011
+ ) / postsWithResults.length).toFixed(2)
3900
4012
  ) : 0
3901
4013
  };
3902
4014
  if (dry_run) {
@@ -3955,8 +4067,13 @@ Created with Social Neuron`;
3955
4067
  }
3956
4068
  }
3957
4069
  lines2.push("");
3958
- lines2.push(`Summary: ${passed}/${workingPlan.posts.length} passed quality check`);
3959
- return { content: [{ type: "text", text: lines2.join("\n") }], isError: false };
4070
+ lines2.push(
4071
+ `Summary: ${passed}/${workingPlan.posts.length} passed quality check`
4072
+ );
4073
+ return {
4074
+ content: [{ type: "text", text: lines2.join("\n") }],
4075
+ isError: false
4076
+ };
3960
4077
  }
3961
4078
  let scheduled = 0;
3962
4079
  let failed = 0;
@@ -4048,7 +4165,8 @@ Created with Social Neuron`;
4048
4165
  }
4049
4166
  const chunk = (arr, size) => {
4050
4167
  const out = [];
4051
- for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
4168
+ for (let i = 0; i < arr.length; i += size)
4169
+ out.push(arr.slice(i, i + size));
4052
4170
  return out;
4053
4171
  };
4054
4172
  const platformBatches = Array.from(grouped.entries()).map(
@@ -4056,7 +4174,9 @@ Created with Social Neuron`;
4056
4174
  const platformResults = [];
4057
4175
  const batches = chunk(platformPosts, batch_size);
4058
4176
  for (const batch of batches) {
4059
- const settled = await Promise.allSettled(batch.map((post) => scheduleOne(post)));
4177
+ const settled = await Promise.allSettled(
4178
+ batch.map((post) => scheduleOne(post))
4179
+ );
4060
4180
  for (const outcome of settled) {
4061
4181
  if (outcome.status === "fulfilled") {
4062
4182
  platformResults.push(outcome.value);
@@ -4109,7 +4229,11 @@ Created with Social Neuron`;
4109
4229
  plan_id: effectivePlanId,
4110
4230
  approvals: approvalSummary,
4111
4231
  posts: results,
4112
- summary: { total_posts: workingPlan.posts.length, scheduled, failed }
4232
+ summary: {
4233
+ total_posts: workingPlan.posts.length,
4234
+ scheduled,
4235
+ failed
4236
+ }
4113
4237
  }),
4114
4238
  null,
4115
4239
  2
@@ -4140,7 +4264,12 @@ Created with Social Neuron`;
4140
4264
  } catch (err) {
4141
4265
  const message = sanitizeError(err);
4142
4266
  return {
4143
- content: [{ type: "text", text: `Batch scheduling failed: ${message}` }],
4267
+ content: [
4268
+ {
4269
+ type: "text",
4270
+ text: `Batch scheduling failed: ${message}`
4271
+ }
4272
+ ],
4144
4273
  isError: true
4145
4274
  };
4146
4275
  }
@@ -9555,7 +9684,7 @@ var TOOL_CATALOG = [
9555
9684
  // niche research
9556
9685
  {
9557
9686
  name: "find_winning_content",
9558
- description: "Find QA-gated high-performing short-form videos in the project's niche. Returns extracted hook patterns, content structures, and pre-compiled replication prompts (backed by niche_winners view, qa_score >= 0.5).",
9687
+ description: "Find QA-gated high-performing short-form videos in the project's niche. Returns extracted hook patterns, content structures, and pre-compiled replication prompts.",
9559
9688
  module: "research",
9560
9689
  scope: "mcp:read"
9561
9690
  },
@@ -9706,59 +9835,68 @@ var TOOL_CATALOG = [
9706
9835
  // agentic-harness — learning loop write-back
9707
9836
  {
9708
9837
  name: "write_agent_reflection",
9709
- description: "Persist a verbal reflection for an agent loop. Provenance keys are restricted (Anti-Goodhart safety): only content_history_id, outcome_event_id, prm_score_ids, and handoff_ids are accepted.",
9838
+ description: "Persist a verbal reflection for an agent loop. Provenance keys are restricted to an allowlist: only content_history_id, outcome_event_id, prm_score_ids, and handoff_ids are accepted.",
9710
9839
  module: "harness",
9711
- scope: "mcp:write"
9840
+ scope: "mcp:write",
9841
+ internal: true
9712
9842
  },
9713
9843
  {
9714
9844
  name: "record_outcome",
9715
- description: "Record an outcome for a published decision event. Idempotent on (decision_event_id, horizon). Only horizon=24h triggers a content_bandits posterior update.",
9845
+ description: "Record an outcome for a published decision event. Idempotent on (decision_event_id, horizon). Only horizon=24h triggers a learning-loop update.",
9716
9846
  module: "harness",
9717
- scope: "mcp:write"
9847
+ scope: "mcp:write",
9848
+ internal: true
9718
9849
  },
9719
9850
  // agentic-harness — learning loop read-back
9720
9851
  {
9721
9852
  name: "read_agent_reflection",
9722
9853
  description: "Read past agent reflections for a brand. Ordered by created_at DESC, id ASC (deterministic tiebreak). Only active reflections returned (superseded_by IS NULL). Optional generated_by_agent filter.",
9723
9854
  module: "harness",
9724
- scope: "mcp:read"
9855
+ scope: "mcp:read",
9856
+ internal: true
9725
9857
  },
9726
9858
  // hermes — autonomous agent integration (closed-loop content)
9727
9859
  {
9728
9860
  name: "save_draft_to_library",
9729
- description: "Save a draft post to the SN content library with origin=hermes. Used by Hermes to persist drafts before the founder approves them.",
9861
+ description: "Save a draft post to the SN content library for review before publishing. Drafts land in the content library pending approval.",
9730
9862
  module: "hermes",
9731
- scope: "mcp:write"
9863
+ scope: "mcp:write",
9864
+ internal: true
9732
9865
  },
9733
9866
  {
9734
9867
  name: "record_voice_lesson",
9735
- description: "Persist a learned voice lesson to brand_profiles.brand_context.voiceProfile.voice_lessons via atomic RPC. Used by Hermes reflection cron.",
9868
+ description: "Persist a learned voice lesson to the brand voice profile.",
9736
9869
  module: "hermes",
9737
- scope: "mcp:write"
9870
+ scope: "mcp:write",
9871
+ internal: true
9738
9872
  },
9739
9873
  {
9740
9874
  name: "record_observation",
9741
- description: 'Record an agent observation (e.g. "topic X engagement up 23%") for the UnifiedAnalytics > Playbook surface.',
9875
+ description: 'Record an agent observation (e.g. "topic X engagement up 23%") for the analytics playbook.',
9742
9876
  module: "hermes",
9743
- scope: "mcp:write"
9877
+ scope: "mcp:write",
9878
+ internal: true
9744
9879
  },
9745
9880
  {
9746
9881
  name: "record_intel_signal",
9747
- description: "Record a research/trend signal from Hermes watchers (news, HN, competitor, etc.) for Niche Intelligence. Dedupes by URL.",
9882
+ description: "Record a research/trend signal (news, competitor, community sources) for niche intelligence. Dedupes by URL.",
9748
9883
  module: "hermes",
9749
- scope: "mcp:write"
9884
+ scope: "mcp:write",
9885
+ internal: true
9750
9886
  },
9751
9887
  {
9752
9888
  name: "record_campaign_spend",
9753
- description: "Log a campaign cost line (hermes_drafts, carousel_renders, analytics_pulls, paid_amplification, other). Ownership-checked.",
9889
+ description: "Log a campaign cost line item. Ownership-checked.",
9754
9890
  module: "hermes",
9755
- scope: "mcp:write"
9891
+ scope: "mcp:write",
9892
+ internal: true
9756
9893
  },
9757
9894
  {
9758
9895
  name: "get_active_campaigns",
9759
- description: "List currently-running campaigns with thesis, budget, hero format, and current spend. Used by Hermes pitch skill to bias drafts.",
9896
+ description: "List currently-running campaigns with thesis, budget, hero format, and current spend.",
9760
9897
  module: "hermes",
9761
- scope: "mcp:read"
9898
+ scope: "mcp:read",
9899
+ internal: true
9762
9900
  },
9763
9901
  // skills (workflow skills — multi-step brand-locked content pipelines)
9764
9902
  {
@@ -9769,20 +9907,20 @@ var TOOL_CATALOG = [
9769
9907
  },
9770
9908
  {
9771
9909
  name: "run_skill",
9772
- description: "Run a Social Neuron workflow skill end-to-end (brand-locked content production). PR #4.4 v1 returns a structured run preview with the step plan, credit cost, and a deep-link to launch in the SN dashboard.",
9910
+ description: "Run a Social Neuron workflow skill end-to-end (brand-locked content production). Returns a structured run preview with the step plan, credit cost, and a deep-link to launch in the SN dashboard.",
9773
9911
  module: "skills",
9774
9912
  scope: "mcp:write"
9775
9913
  },
9776
- // loop observability (growth-loop KPIs + Thompson Sampling bandit posteriors)
9914
+ // loop observability (growth-loop KPIs + content learning state)
9777
9915
  {
9778
9916
  name: "get_loop_pulse",
9779
- description: "Read dynamic loop-health KPIs for the growth loop over the last 7 days (reflection/decision coverage, visual gate pass rate, bandit-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.",
9917
+ 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.",
9780
9918
  module: "loop",
9781
9919
  scope: "mcp:read"
9782
9920
  },
9783
9921
  {
9784
9922
  name: "get_bandit_state",
9785
- description: "Read the current Thompson Sampling bandit posteriors for a project \u2014 top-K arms per (arm_type, platform) with Beta(alpha,beta) posterior mean and uncertainty. Use to reason about which hook family / format / timing slot the bandit currently prefers per platform.",
9923
+ 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.",
9786
9924
  module: "loop",
9787
9925
  scope: "mcp:read"
9788
9926
  }
@@ -9892,7 +10030,10 @@ function toolKnowledgeDocument(tool) {
9892
10030
  };
9893
10031
  }
9894
10032
  function getKnowledgeDocuments() {
9895
- return [...STATIC_KNOWLEDGE_DOCUMENTS, ...TOOL_CATALOG.map(toolKnowledgeDocument)];
10033
+ return [
10034
+ ...STATIC_KNOWLEDGE_DOCUMENTS,
10035
+ ...TOOL_CATALOG.filter((t) => !t.internal).map(toolKnowledgeDocument)
10036
+ ];
9896
10037
  }
9897
10038
  function tokenize(input) {
9898
10039
  return input.toLowerCase().split(/[^a-z0-9:_-]+/).map((token) => token.trim()).filter(Boolean);
@@ -10008,6 +10149,7 @@ function registerDiscoveryTools(server) {
10008
10149
  if (query) {
10009
10150
  results = searchTools(query);
10010
10151
  }
10152
+ results = results.filter((t) => !t.internal);
10011
10153
  if (module) {
10012
10154
  const moduleTools = getToolsByModule(module);
10013
10155
  results = results.filter((t) => moduleTools.some((mt) => mt.name === t.name));
@@ -10084,6 +10226,7 @@ var PLATFORM_ENUM2 = z24.enum([
10084
10226
  ]);
10085
10227
  var BASE_PLAN_CREDITS = 15;
10086
10228
  var SOURCE_EXTRACTION_CREDITS = 5;
10229
+ var SCHEDULE_POST_CREDITS = 1;
10087
10230
  function registerPipelineTools(server) {
10088
10231
  server.tool(
10089
10232
  "check_pipeline_readiness",
@@ -10200,7 +10343,7 @@ function registerPipelineTools(server) {
10200
10343
  );
10201
10344
  server.tool(
10202
10345
  "run_content_pipeline",
10203
- "Run the full content pipeline: research trends \u2192 generate plan \u2192 quality check \u2192 auto-approve \u2192 schedule posts. Chains all stages in one call for maximum efficiency. Set dry_run=true to preview the plan without publishing. Check check_pipeline_readiness first to verify credits, OAuth, and brand profile are ready.",
10346
+ "Run the full content pipeline: research trends \u2192 generate plan \u2192 quality check \u2192 auto-approve \u2192 schedule posts. Chains all stages in one call for maximum efficiency. Set dry_run=true to preview the plan without publishing. To schedule posts, set schedule_confirmed=true after the user explicitly approves publishing. Check check_pipeline_readiness first to verify credits, OAuth, and brand profile are ready.",
10204
10347
  {
10205
10348
  project_id: z24.string().uuid().optional().describe("Project ID (auto-detected if omitted)"),
10206
10349
  topic: z24.string().optional().describe("Content topic (required if no source_url)"),
@@ -10216,6 +10359,9 @@ function registerPipelineTools(server) {
10216
10359
  ),
10217
10360
  max_credits: z24.number().optional().describe("Credit budget cap"),
10218
10361
  dry_run: z24.boolean().default(false).describe("If true, skip scheduling and return plan only"),
10362
+ schedule_confirmed: z24.boolean().default(false).describe(
10363
+ "Required to schedule posts. Set true only after explicit user confirmation to publish/schedule."
10364
+ ),
10219
10365
  skip_stages: z24.array(z24.enum(["research", "quality", "schedule"])).optional().describe("Stages to skip"),
10220
10366
  response_format: z24.enum(["text", "json"]).default("json")
10221
10367
  },
@@ -10230,6 +10376,7 @@ function registerPipelineTools(server) {
10230
10376
  auto_approve_threshold,
10231
10377
  max_credits,
10232
10378
  dry_run,
10379
+ schedule_confirmed,
10233
10380
  skip_stages,
10234
10381
  response_format
10235
10382
  }) => {
@@ -10245,6 +10392,29 @@ function registerPipelineTools(server) {
10245
10392
  };
10246
10393
  }
10247
10394
  const skipSet = new Set(skip_stages ?? []);
10395
+ const schedulingRequested = !dry_run && !skipSet.has("schedule");
10396
+ if (schedulingRequested && !schedule_confirmed) {
10397
+ return {
10398
+ content: [
10399
+ {
10400
+ type: "text",
10401
+ text: 'Scheduling requires explicit confirmation. Re-run with schedule_confirmed=true after the user approves publishing, or set dry_run=true / skip_stages=["schedule"].'
10402
+ }
10403
+ ],
10404
+ isError: true
10405
+ };
10406
+ }
10407
+ if (schedulingRequested && skipSet.has("quality")) {
10408
+ return {
10409
+ content: [
10410
+ {
10411
+ type: "text",
10412
+ text: "Scheduling cannot run when the quality stage is skipped."
10413
+ }
10414
+ ],
10415
+ isError: true
10416
+ };
10417
+ }
10248
10418
  try {
10249
10419
  const resolvedProjectId = project_id ?? await getDefaultProjectId() ?? void 0;
10250
10420
  const estimatedCost = BASE_PLAN_CREDITS + (source_url ? SOURCE_EXTRACTION_CREDITS : 0);
@@ -10287,6 +10457,7 @@ function registerPipelineTools(server) {
10287
10457
  approval_mode,
10288
10458
  auto_approve_threshold,
10289
10459
  dry_run,
10460
+ schedule_confirmed,
10290
10461
  skip_stages: skip_stages ?? []
10291
10462
  },
10292
10463
  current_stage: "planning",
@@ -10352,7 +10523,9 @@ function registerPipelineTools(server) {
10352
10523
  stagesCompleted.push("planning");
10353
10524
  const rawText = String(planData.text ?? planData.content ?? "");
10354
10525
  const postsArray = extractJsonArray(rawText);
10355
- const posts = (postsArray ?? []).map((p) => ({
10526
+ const requestedPlatformSet = new Set(platforms);
10527
+ const maxPosts = platforms.length * days * posts_per_day;
10528
+ const parsedPosts = (postsArray ?? []).map((p) => ({
10356
10529
  id: String(p.id ?? randomUUID3().slice(0, 8)),
10357
10530
  day: Number(p.day ?? 1),
10358
10531
  date: String(p.date ?? ""),
@@ -10366,6 +10539,23 @@ function registerPipelineTools(server) {
10366
10539
  visual_direction: p.visual_direction ? String(p.visual_direction) : void 0,
10367
10540
  media_type: p.media_type ? String(p.media_type) : void 0
10368
10541
  }));
10542
+ const platformFilteredPosts = parsedPosts.filter(
10543
+ (post) => requestedPlatformSet.has(post.platform)
10544
+ );
10545
+ const posts = platformFilteredPosts.slice(0, maxPosts);
10546
+ if (parsedPosts.length > maxPosts) {
10547
+ errors.push({
10548
+ stage: "planning",
10549
+ message: `AI returned ${parsedPosts.length} posts; truncated to ${maxPosts}.`
10550
+ });
10551
+ }
10552
+ const invalidPlatformCount = parsedPosts.length - platformFilteredPosts.length;
10553
+ if (invalidPlatformCount > 0) {
10554
+ errors.push({
10555
+ stage: "planning",
10556
+ message: `Dropped ${invalidPlatformCount} post(s) with unrequested or invalid platform.`
10557
+ });
10558
+ }
10369
10559
  let postsApproved = 0;
10370
10560
  let postsFlagged = 0;
10371
10561
  if (!skipSet.has("quality")) {
@@ -10518,6 +10708,7 @@ function registerPipelineTools(server) {
10518
10708
  });
10519
10709
  } else {
10520
10710
  postsScheduled++;
10711
+ creditsUsed += SCHEDULE_POST_CREDITS;
10521
10712
  }
10522
10713
  } catch (schedErr) {
10523
10714
  errors.push({
@@ -12577,13 +12768,13 @@ function asEnvelope24(data) {
12577
12768
  function registerNicheResearchTools(server) {
12578
12769
  server.tool(
12579
12770
  "find_winning_content",
12580
- "Find QA-gated high-performing short-form videos in the project's niche. Returns extracted hook patterns, content structures, and pre-compiled replication prompts you can use to generate new content on a different topic. Backed by niche_winners view (qa_score >= 0.5 + replication_prompt populated).",
12771
+ "Find QA-gated high-performing short-form videos in the project's niche. Returns extracted hook patterns, content structures, and pre-compiled replication prompts you can use to generate new content on a different topic. Only QA-gated winners with a populated replication prompt are returned.",
12581
12772
  {
12582
12773
  project_id: z29.string().uuid().optional().describe("Project ID (auto-detected if omitted)"),
12583
12774
  platform: z29.enum(["tiktok", "instagram", "youtube", "reddit", "twitter"]).optional().describe("Filter to one platform. Omit for all platforms."),
12584
12775
  days: z29.number().int().min(1).max(365).default(30).describe("Window: only return winners scanned within this many days."),
12585
12776
  limit: z29.number().int().min(1).max(50).default(10).describe("Number of winners to return (1-50)."),
12586
- min_qa_score: z29.number().min(0).max(1).default(0.5).describe("Minimum QA score (0..1). Default 0.5 matches the niche_winners view floor."),
12777
+ min_qa_score: z29.number().min(0).max(1).default(0.5).describe("Minimum QA score (0..1). Default 0.5."),
12587
12778
  response_format: z29.enum(["text", "json"]).optional()
12588
12779
  },
12589
12780
  async ({ project_id, platform: platform2, days, limit, min_qa_score, response_format }) => {
@@ -13241,7 +13432,7 @@ var writeReflectionSchema = {
13241
13432
  prm_score_ids: z33.array(z33.string()).optional().describe("PRM score IDs that informed this reflection."),
13242
13433
  handoff_ids: z33.array(z33.string()).optional().describe("Handoff event IDs that triggered this reflection.")
13243
13434
  }).strict().describe(
13244
- "Source evidence for this reflection. Only these four keys are accepted (Anti-Goodhart guard \u2014 unknown keys are rejected to prevent spurious provenance claims)."
13435
+ "Source evidence for this reflection. Only these four keys are accepted \u2014 unknown keys are rejected to prevent spurious provenance claims."
13245
13436
  ),
13246
13437
  brand_id: z33.string().describe("Brand profile UUID this reflection belongs to."),
13247
13438
  pipeline_id: z33.string().optional().describe("Optional: pipeline run UUID."),
@@ -13259,7 +13450,7 @@ var readReflectionSchema = {
13259
13450
  var recordOutcomeSchema = {
13260
13451
  decision_event_id: z33.string().describe("UUID of the decision_events row to record an outcome for."),
13261
13452
  horizon: z33.enum(["1h", "6h", "24h"]).describe(
13262
- "Observation horizon. Only horizon=24h with a non-null reward triggers a content_bandits posterior update; 1h/6h are stored but inert for learning."
13453
+ "Observation horizon. Only horizon=24h with a non-null reward triggers a learning-loop update; 1h/6h are stored but inert for learning."
13263
13454
  ),
13264
13455
  reward: z33.number().min(0).max(1).describe("Normalised reward signal in [0, 1]. Higher = better outcome."),
13265
13456
  outcome_metrics: z33.record(z33.string(), z33.number()).optional().describe(
@@ -13269,7 +13460,7 @@ var recordOutcomeSchema = {
13269
13460
  function registerHarnessTools(server, _ctx) {
13270
13461
  server.tool(
13271
13462
  "write_agent_reflection",
13272
- "Persist a verbal reflection for an agent loop. Provenance keys are restricted (Anti-Goodhart safety): only content_history_id, outcome_event_id, prm_score_ids, and handoff_ids are accepted \u2014 unknown keys are rejected at the input layer. Requires mcp:write scope. Returns the created reflection UUID on success.",
13463
+ "Persist a verbal reflection for an agent loop. Provenance keys are restricted to an allowlist: only content_history_id, outcome_event_id, prm_score_ids, and handoff_ids are accepted \u2014 unknown keys are rejected at the input layer. Requires mcp:write scope. Returns the created reflection UUID on success.",
13273
13464
  writeReflectionSchema,
13274
13465
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
13275
13466
  async (args) => {
@@ -13297,7 +13488,7 @@ function registerHarnessTools(server, _ctx) {
13297
13488
  );
13298
13489
  server.tool(
13299
13490
  "record_outcome",
13300
- "Record an outcome for a published decision event. Idempotent on (decision_event_id, horizon) \u2014 safe to call multiple times. Returns idempotent:true when the row already existed (UPDATE), idempotent:false on fresh INSERT. Note: only horizon=24h with reward != null triggers a content_bandits posterior update; 1h/6h outcomes are stored but are inert for the learning loop. Requires mcp:write scope.",
13491
+ "Record an outcome for a published decision event. Idempotent on (decision_event_id, horizon) \u2014 safe to call multiple times. Returns idempotent:true when the row already existed (UPDATE), idempotent:false on fresh INSERT. Note: only horizon=24h with reward != null triggers a learning-loop update; 1h/6h outcomes are stored but are inert for the learning loop. Requires mcp:write scope.",
13301
13492
  recordOutcomeSchema,
13302
13493
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
13303
13494
  async (args) => {
@@ -13381,13 +13572,13 @@ var PLATFORM = z34.enum([
13381
13572
  function registerHermesTools(server) {
13382
13573
  server.tool(
13383
13574
  "save_draft_to_library",
13384
- "Save a draft post to the SN content library. Use when an autonomous agent (Hermes) wants to persist a draft before the founder approves it. Lands in ContentLibrary with status='draft', origin='hermes'. The draft can then be approved/edited in the SN UI.",
13575
+ "Save a draft post to the SN content library. Use when an autonomous agent wants to persist a draft for review before publishing. Lands in the content library with status='draft'. The draft can then be approved/edited in the SN UI.",
13385
13576
  {
13386
13577
  platform: PLATFORM.describe("Target platform for the draft."),
13387
13578
  copy: z34.string().min(1).max(8e3).describe("The draft post body."),
13388
13579
  project_id: z34.string().optional().describe("SN project UUID. Optional but recommended."),
13389
13580
  media_url: z34.string().url().optional().describe("Optional cover/media URL (R2 signed URL)."),
13390
- hermes_run_id: z34.string().optional().describe("Hermes cron run id, for traceability."),
13581
+ hermes_run_id: z34.string().optional().describe("Agent run id, for traceability."),
13391
13582
  source_intel_ids: z34.array(z34.string()).optional().describe("Optional list of intel_signals.id rows that informed this draft."),
13392
13583
  response_format: z34.enum(["text", "json"]).optional()
13393
13584
  },
@@ -13469,11 +13660,11 @@ function registerHermesTools(server) {
13469
13660
  );
13470
13661
  server.tool(
13471
13662
  "record_observation",
13472
- 'Record an agent observation ("Hermes noticed X this week"). Surfaces in UnifiedAnalytics > Playbook tab. Use for weekly reflection digests and mid-campaign pulse summaries.',
13663
+ 'Record an agent observation (e.g. "topic X engagement up 23% this week"). Surfaces in the analytics Playbook. Use for weekly reflection digests and mid-campaign pulse summaries.',
13473
13664
  {
13474
- summary: z34.string().min(1).max(2e3).describe("One-paragraph summary, founder-facing."),
13665
+ summary: z34.string().min(1).max(2e3).describe("One-paragraph summary, shown to the account owner."),
13475
13666
  deltas: z34.record(z34.string(), z34.union([z34.number(), z34.string(), z34.boolean()])).optional().describe("Optional structured key/value payload (e.g. {topic_x_er_pct: 23})."),
13476
- run_id: z34.string().optional().describe("Hermes cron run id for traceability."),
13667
+ run_id: z34.string().optional().describe("Agent run id for traceability."),
13477
13668
  response_format: z34.enum(["text", "json"]).optional()
13478
13669
  },
13479
13670
  async ({ summary, deltas, run_id, response_format }) => {
@@ -13543,7 +13734,7 @@ function registerHermesTools(server) {
13543
13734
  );
13544
13735
  server.tool(
13545
13736
  "record_campaign_spend",
13546
- "Log a campaign cost line. Use when Hermes incurs spend that should be attributed to a campaign \u2014 drafts, renders, paid amp. Categories: hermes_drafts / carousel_renders / analytics_pulls / paid_amplification / other. Read aggregate via get_active_campaigns.",
13737
+ "Log a campaign cost line. Use when an autonomous agent incurs spend that should be attributed to a campaign \u2014 drafts, renders, paid amp. Read aggregate via get_active_campaigns.",
13547
13738
  {
13548
13739
  campaign_id: z34.string().min(1).max(200).describe("Campaign identifier (slug)."),
13549
13740
  category: z34.enum([
@@ -13587,7 +13778,7 @@ function registerHermesTools(server) {
13587
13778
  );
13588
13779
  server.tool(
13589
13780
  "get_active_campaigns",
13590
- "List currently-running campaigns with thesis, budget, hero format, and current spend. Used by Hermes pitch skill to bias drafts toward active campaign themes.",
13781
+ "List currently-running campaigns with thesis, budget, hero format, and current spend. Use to bias drafts toward active campaign themes.",
13591
13782
  {
13592
13783
  response_format: z34.enum(["text", "json"]).optional()
13593
13784
  },
@@ -13730,7 +13921,7 @@ ${blocks}` }]
13730
13921
  );
13731
13922
  server.tool(
13732
13923
  "run_skill",
13733
- "Run a Social Neuron workflow skill end-to-end (brand-locked content production). PR #4.4 v1: returns a structured run preview with the exact step plan, credit cost, and a deep-link to launch the run in the SN dashboard. PR #4.5 (next release) executes in-process so you can stream step-by-step progress back to the user. Call list_skills first to discover available skill ids.",
13924
+ "Run a Social Neuron workflow skill end-to-end (brand-locked content production). Returns a structured run preview with the exact step plan, credit cost, and a deep-link to launch the run in the SN dashboard. A future release executes in-process so you can stream step-by-step progress back to the user. Call list_skills first to discover available skill ids.",
13734
13925
  {
13735
13926
  skill_id: z35.string().describe(
13736
13927
  'The id of the skill to run (e.g. "skill-brand-locked-viral-hook-reel"). Use list_skills to discover available ids.'
@@ -13775,7 +13966,7 @@ ${blocks}` }]
13775
13966
  inputs,
13776
13967
  runUrl,
13777
13968
  previewedAt,
13778
- note: "PR #4.4 v1 returns a preview. PR #4.5 will execute in-process via the run-skill EF."
13969
+ note: "This release returns a preview. A future release will execute in-process."
13779
13970
  }),
13780
13971
  null,
13781
13972
  2
@@ -13804,7 +13995,7 @@ ${blocks}` }]
13804
13995
  "",
13805
13996
  `Launch in dashboard: ${runUrl}`,
13806
13997
  "",
13807
- "\u26A0\uFE0F PR #4.4 v1: preview only. End-to-end MCP execution arrives in the next release."
13998
+ "\u26A0\uFE0F Preview only. End-to-end MCP execution arrives in a future release."
13808
13999
  ];
13809
14000
  return {
13810
14001
  content: [{ type: "text", text: lines.join("\n") }]
@@ -13824,7 +14015,7 @@ function asEnvelope27(data) {
13824
14015
  function registerLoopPulseTools(server) {
13825
14016
  server.tool(
13826
14017
  "get_loop_pulse",
13827
- 'Read the dynamic loop-health KPIs for the Social Neuron growth loop over the last 7 days. Returns reflection coverage, decision coverage, visual gate pass rate, bandit-update application rate, per-platform bandit uptake, autopilot lag, and pattern aggregation counts \u2014 each with a status ("ok" / "warn" / "bad") and a why-line explaining what the metric measures. Use this to decide whether the loop is closing or where it is stuck before recommending next moves.',
14018
+ 'Read the dynamic loop-health KPIs for the Social Neuron growth loop over the last 7 days. Returns reflection coverage, decision coverage, visual gate pass rate, learning-update application rate, per-platform learning uptake, autopilot lag, and pattern aggregation counts \u2014 each with a status ("ok" / "warn" / "bad") and a why-line explaining what the metric measures. Use this to decide whether the loop is closing or where it is stuck before recommending next moves.',
13828
14019
  {
13829
14020
  response_format: z36.enum(["text", "json"]).optional().describe("Output format. Defaults to text.")
13830
14021
  },
@@ -13880,7 +14071,7 @@ function asEnvelope28(data) {
13880
14071
  function registerBanditStateTools(server) {
13881
14072
  server.tool(
13882
14073
  "get_bandit_state",
13883
- "Read the current Thompson Sampling bandit posteriors for a project. Returns top-K arms per (arm_type, platform) with Beta(alpha, beta) posterior mean and uncertainty. Use this to reason about which hook family / format / timing slot the bandit currently prefers on each platform before recommending next moves. SN real arm types: hook_family (6 fallback families), length_bucket (xs/s/m/l/xl by platform), posting_time_bucket (morning/midday/evening/late), content_format (video/carousel/image/caption/text/avatar/storyboard). Legacy/dead-taxonomy types also present: hook_type, format, timing_slot, topic_cluster, caption_style, platform, story_type, emoji_type.",
14074
+ "Read the current content learning state for a project. Returns top-K arms per (arm_type, platform) with expected performance and uncertainty. Use this to reason about which hook family / format / timing slot currently performs best on each platform before recommending next moves. Arm types: hook_family, length_bucket (xs/s/m/l/xl by platform), posting_time_bucket (morning/midday/evening/late), content_format (video/carousel/image/caption/text/avatar/storyboard).",
13884
14075
  {
13885
14076
  project_id: z37.string().uuid().optional().describe("Project UUID. Defaults to the authenticated user default project."),
13886
14077
  platform: z37.enum([
@@ -13931,9 +14122,7 @@ function registerBanditStateTools(server) {
13931
14122
  });
13932
14123
  if (error || !data) {
13933
14124
  return {
13934
- content: [
13935
- { type: "text", text: `Failed to read bandit state: ${error || "no data"}` }
13936
- ],
14125
+ content: [{ type: "text", text: `Failed to read bandit state: ${error || "no data"}` }],
13937
14126
  isError: true
13938
14127
  };
13939
14128
  }
@@ -13983,15 +14172,12 @@ function wrapToolWithScanner(toolName, handler) {
13983
14172
  ctx?.logScan?.(toolName, "input", inputScan);
13984
14173
  } catch {
13985
14174
  }
13986
- return {
13987
- content: [
13988
- {
13989
- type: "text",
13990
- text: `harness:input_blocked patterns=${inputScan.flagged_patterns.join(",")}`
13991
- }
13992
- ],
13993
- isError: true
13994
- };
14175
+ return toolError("policy_block", "Request blocked by the input safety policy.", {
14176
+ details: { blocked_patterns: inputScan.flagged_patterns },
14177
+ recover_with: [
14178
+ "Remove instruction-like or unsafe phrasing from the input and retry."
14179
+ ]
14180
+ });
13995
14181
  }
13996
14182
  const result = await handler(...handlerArgs);
13997
14183
  const outputText = JSON.stringify(result);
@@ -14074,34 +14260,34 @@ function applyScopeEnforcement(server, scopeResolver) {
14074
14260
  }
14075
14261
  }
14076
14262
  function scopeDeniedResult(name, requiredScope, userScopes) {
14077
- const error = requiredScope ? {
14078
- error: "permission_denied",
14079
- tool: name,
14080
- required_scope: requiredScope,
14081
- available_scopes: userScopes,
14082
- recover_with: [
14083
- "Call search_tools with available_only=true to find tools this key can use.",
14084
- "Use a read-only alternative if one is available for the task.",
14085
- "Regenerate the API key with the required scope or upgrade the plan tier."
14086
- ],
14087
- developer_url: "https://socialneuron.com/settings/developer"
14088
- } : {
14089
- error: "tool_scope_missing",
14090
- tool: name,
14091
- available_scopes: userScopes,
14092
- recover_with: ["Contact support; this tool is not mapped to a required scope."]
14093
- };
14094
14263
  const challenge = requiredScope ? buildWwwAuthenticateHeader({
14095
14264
  issuerUrl: getChallengeIssuerUrl(),
14096
14265
  error: "insufficient_scope",
14097
14266
  errorDescription: `Tool ${name} requires scope ${requiredScope}.`,
14098
14267
  scope: requiredScope
14099
14268
  }) : void 0;
14100
- return {
14101
- content: [{ type: "text", text: JSON.stringify(error, null, 2) }],
14102
- ...challenge ? { _meta: { "mcp/www_authenticate": [challenge] } } : {},
14103
- isError: true
14104
- };
14269
+ if (requiredScope) {
14270
+ return toolError("permission_denied", `Tool ${name} requires scope ${requiredScope}.`, {
14271
+ details: {
14272
+ // Preserve the pre-#188 field names for existing clients that branch on them.
14273
+ error: "permission_denied",
14274
+ tool: name,
14275
+ required_scope: requiredScope,
14276
+ available_scopes: userScopes,
14277
+ developer_url: "https://socialneuron.com/settings/developer"
14278
+ },
14279
+ recover_with: [
14280
+ "Call search_tools with available_only=true to find tools this key can use.",
14281
+ "Use a read-only alternative if one is available for the task.",
14282
+ "Regenerate the API key with the required scope or upgrade the plan tier."
14283
+ ],
14284
+ ...challenge ? { meta: { "mcp/www_authenticate": [challenge] } } : {}
14285
+ });
14286
+ }
14287
+ return toolError("server_error", `Tool ${name} is not mapped to a required scope.`, {
14288
+ details: { error: "tool_scope_missing", tool: name, available_scopes: userScopes },
14289
+ recover_with: ["Contact support; this tool is not mapped to a required scope."]
14290
+ });
14105
14291
  }
14106
14292
  function getChallengeIssuerUrl() {
14107
14293
  if (process.env.OAUTH_ISSUER_URL) return process.env.OAUTH_ISSUER_URL;
@@ -14554,10 +14740,20 @@ function registerResources(server) {
14554
14740
  tiers: {
14555
14741
  free: {
14556
14742
  price: "$0/mo",
14557
- credits: 100,
14743
+ credits: 50,
14558
14744
  mcp_access: "None",
14559
14745
  features: ["5 free tools", "Basic content generation"]
14560
14746
  },
14747
+ trial: {
14748
+ price: "$0 (14 days)",
14749
+ credits: 300,
14750
+ mcp_access: "Full engine (read + write + distribute + analytics), 15 rpm",
14751
+ features: [
14752
+ "Full MCP agent engine for 14 days",
14753
+ "One-time 300 credits",
14754
+ "Generate, gate, and publish via MCP"
14755
+ ]
14756
+ },
14561
14757
  starter: {
14562
14758
  price: "$19/mo",
14563
14759
  credits: 500,
@@ -14682,6 +14878,265 @@ function registerResources(server) {
14682
14878
  // src/http.ts
14683
14879
  init_request_context();
14684
14880
 
14881
+ // src/lib/discovery-catalog.ts
14882
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
14883
+ var cached = null;
14884
+ function buildDiscoveryCatalog() {
14885
+ if (!cached) cached = computeDiscoveryCatalog();
14886
+ return cached;
14887
+ }
14888
+ async function computeDiscoveryCatalog() {
14889
+ const schemaByName = /* @__PURE__ */ new Map();
14890
+ try {
14891
+ const probe = new McpServer({ name: "discovery-probe", version: MCP_VERSION });
14892
+ registerAllTools(probe, { skipScreenshots: true });
14893
+ const handlers = probe.server._requestHandlers;
14894
+ const listHandler = handlers.get("tools/list");
14895
+ if (listHandler) {
14896
+ const out = await listHandler({ method: "tools/list", params: {} }, {});
14897
+ for (const t of out.tools) {
14898
+ const props = t.inputSchema?.properties;
14899
+ if (props && Object.keys(props).length > 0) {
14900
+ schemaByName.set(t.name, {
14901
+ type: "object",
14902
+ properties: props,
14903
+ ...t.inputSchema?.required ? { required: t.inputSchema.required } : {}
14904
+ });
14905
+ }
14906
+ }
14907
+ }
14908
+ } catch (err) {
14909
+ console.error(
14910
+ "[mcp] discovery schema build failed; serving names-only catalog:",
14911
+ err?.message
14912
+ );
14913
+ }
14914
+ return TOOL_CATALOG.filter((t) => !t.localOnly && !t.internal).map((t) => ({
14915
+ name: t.name,
14916
+ description: t.description,
14917
+ inputSchema: schemaByName.get(t.name) ?? { type: "object", properties: {} }
14918
+ }));
14919
+ }
14920
+
14921
+ // src/lib/openapi.ts
14922
+ var SERVER_URL = process.env.MCP_SERVER_URL ? `${process.env.MCP_SERVER_URL.replace(/\/$/, "")}/v1` : "https://mcp.socialneuron.com/v1";
14923
+ var TOOL_ERROR_SCHEMA = {
14924
+ type: "object",
14925
+ required: ["error"],
14926
+ properties: {
14927
+ error: {
14928
+ type: "object",
14929
+ required: ["error_type", "message"],
14930
+ properties: {
14931
+ error_type: {
14932
+ type: "string",
14933
+ enum: [
14934
+ "policy_block",
14935
+ "validation_error",
14936
+ "permission_denied",
14937
+ "billing_error",
14938
+ "rate_limited",
14939
+ "not_found",
14940
+ "upstream_error",
14941
+ "server_error"
14942
+ ],
14943
+ description: "Machine-readable error classification."
14944
+ },
14945
+ message: { type: "string" },
14946
+ recover_with: { type: "array", items: { type: "string" } }
14947
+ },
14948
+ additionalProperties: true
14949
+ }
14950
+ }
14951
+ };
14952
+ var TOOL_RESULT_SCHEMA = {
14953
+ type: "object",
14954
+ description: "MCP CallToolResult. `content` carries text/structured blocks; `structuredContent` is present when the tool declares structured output.",
14955
+ properties: {
14956
+ content: {
14957
+ type: "array",
14958
+ items: {
14959
+ type: "object",
14960
+ properties: {
14961
+ type: { type: "string" },
14962
+ text: { type: "string" }
14963
+ },
14964
+ additionalProperties: true
14965
+ }
14966
+ },
14967
+ structuredContent: { type: "object", additionalProperties: true },
14968
+ isError: { type: "boolean" }
14969
+ }
14970
+ };
14971
+ var ERROR_RESPONSE = (description) => ({
14972
+ description,
14973
+ content: { "application/json": { schema: { $ref: "#/components/schemas/ToolError" } } }
14974
+ });
14975
+ var cached2 = null;
14976
+ function buildOpenApiDocument() {
14977
+ if (!cached2) cached2 = computeOpenApiDocument();
14978
+ return cached2;
14979
+ }
14980
+ async function computeOpenApiDocument() {
14981
+ const discovery = await buildDiscoveryCatalog();
14982
+ const schemaByName = new Map(discovery.map((t) => [t.name, t.inputSchema]));
14983
+ const publicTools = TOOL_CATALOG.filter((t) => !t.localOnly && !t.internal);
14984
+ const paths = {};
14985
+ for (const tool of publicTools) {
14986
+ const scope = TOOL_SCOPES[tool.name];
14987
+ const inputSchema = schemaByName.get(tool.name) ?? {
14988
+ type: "object",
14989
+ properties: {}
14990
+ };
14991
+ paths[`/tools/${tool.name}`] = {
14992
+ post: {
14993
+ operationId: tool.name,
14994
+ summary: tool.description,
14995
+ tags: [tool.module],
14996
+ ...scope ? { "x-required-scope": scope } : {},
14997
+ security: [{ bearerAuth: [] }],
14998
+ requestBody: {
14999
+ required: Object.keys(inputSchema.properties ?? {}).length > 0,
15000
+ content: { "application/json": { schema: inputSchema } }
15001
+ },
15002
+ responses: {
15003
+ "200": {
15004
+ description: "Tool executed. `isError:true` in the body indicates a tool-level error.",
15005
+ content: {
15006
+ "application/json": { schema: { $ref: "#/components/schemas/ToolResult" } }
15007
+ }
15008
+ },
15009
+ "400": ERROR_RESPONSE("Validation error or policy block."),
15010
+ "401": { description: "Missing or invalid bearer token." },
15011
+ "402": ERROR_RESPONSE("Billing error (insufficient credits)."),
15012
+ "403": ERROR_RESPONSE("Insufficient scope for this tool."),
15013
+ "404": ERROR_RESPONSE("Referenced object not found."),
15014
+ "429": ERROR_RESPONSE("Rate limit exceeded.")
15015
+ }
15016
+ }
15017
+ };
15018
+ }
15019
+ return {
15020
+ openapi: "3.1.0",
15021
+ info: {
15022
+ title: "Social Neuron REST API",
15023
+ version: MCP_VERSION,
15024
+ description: "REST projection of the Social Neuron MCP tool catalog. Every tool is callable as `POST /v1/tools/{name}` with the same auth, scopes, rate limits, and credit pool as the hosted MCP endpoint. Generated from the tool catalog \u2014 never hand-edited.",
15025
+ license: { name: "MIT" }
15026
+ },
15027
+ servers: [{ url: SERVER_URL }],
15028
+ security: [{ bearerAuth: [] }],
15029
+ tags: [...new Set(publicTools.map((t) => t.module))].sort().map((m) => ({ name: m })),
15030
+ paths,
15031
+ components: {
15032
+ securitySchemes: {
15033
+ bearerAuth: {
15034
+ type: "http",
15035
+ scheme: "bearer",
15036
+ description: 'Social Neuron API key (`snk_live_\u2026`) or OAuth access token. Scopes are derived from the plan tier; a 403 with `error_type:"permission_denied"` signals an upgrade is needed.'
15037
+ }
15038
+ },
15039
+ schemas: {
15040
+ ToolResult: TOOL_RESULT_SCHEMA,
15041
+ ToolError: TOOL_ERROR_SCHEMA
15042
+ }
15043
+ }
15044
+ };
15045
+ }
15046
+
15047
+ // src/lib/rest-invoke.ts
15048
+ import { McpServer as McpServer2 } from "@modelcontextprotocol/sdk/server/mcp.js";
15049
+ init_request_context();
15050
+ var cachedCallHandler;
15051
+ function getCallHandler() {
15052
+ if (cachedCallHandler !== void 0) return cachedCallHandler;
15053
+ try {
15054
+ const server = new McpServer2({ name: "socialneuron-rest", version: MCP_VERSION });
15055
+ applyScopeEnforcement(server, () => getRequestScopes() ?? []);
15056
+ registerAllTools(server, { skipScreenshots: true });
15057
+ const handlers = server.server._requestHandlers;
15058
+ cachedCallHandler = handlers.get("tools/call") ?? null;
15059
+ } catch (err) {
15060
+ console.error("[rest] failed to build REST invocation server:", err?.message);
15061
+ cachedCallHandler = null;
15062
+ }
15063
+ return cachedCallHandler;
15064
+ }
15065
+ function restToolNames() {
15066
+ return new Set(TOOL_CATALOG.filter((t) => !t.localOnly && !t.internal).map((t) => t.name));
15067
+ }
15068
+ async function invokeToolRest(name, args) {
15069
+ const handler = getCallHandler();
15070
+ if (!handler) {
15071
+ return {
15072
+ isError: true,
15073
+ structuredContent: {
15074
+ error: { error_type: "server_error", message: "REST invocation is unavailable." }
15075
+ },
15076
+ content: [
15077
+ {
15078
+ type: "text",
15079
+ text: JSON.stringify({
15080
+ error_type: "server_error",
15081
+ message: "REST invocation is unavailable."
15082
+ })
15083
+ }
15084
+ ]
15085
+ };
15086
+ }
15087
+ return handler({ method: "tools/call", params: { name, arguments: args ?? {} } }, {});
15088
+ }
15089
+ var KNOWN_ERROR_TYPES = /* @__PURE__ */ new Set([
15090
+ "policy_block",
15091
+ "validation_error",
15092
+ "permission_denied",
15093
+ "billing_error",
15094
+ "rate_limited",
15095
+ "not_found",
15096
+ "upstream_error",
15097
+ "server_error"
15098
+ ]);
15099
+ function extractRestError(result) {
15100
+ const sc = result.structuredContent?.error;
15101
+ if (sc?.error_type && KNOWN_ERROR_TYPES.has(sc.error_type)) {
15102
+ return { error_type: sc.error_type, message: sc.message ?? "Tool error." };
15103
+ }
15104
+ const text = result.content?.find((c) => c.type === "text")?.text ?? "";
15105
+ try {
15106
+ const parsed = JSON.parse(text);
15107
+ if (parsed.error_type && KNOWN_ERROR_TYPES.has(parsed.error_type)) {
15108
+ return { error_type: parsed.error_type, message: parsed.message ?? text };
15109
+ }
15110
+ } catch {
15111
+ }
15112
+ if (/-32602|Input validation error|Invalid arguments/i.test(text)) {
15113
+ return { error_type: "validation_error", message: "Invalid arguments for this tool." };
15114
+ }
15115
+ return { error_type: "server_error", message: text || "Tool error." };
15116
+ }
15117
+ function httpStatusForResult(result) {
15118
+ if (!result.isError) return 200;
15119
+ switch (extractRestError(result).error_type) {
15120
+ case "validation_error":
15121
+ case "policy_block":
15122
+ return 400;
15123
+ case "billing_error":
15124
+ return 402;
15125
+ case "permission_denied":
15126
+ return 403;
15127
+ case "not_found":
15128
+ return 404;
15129
+ case "rate_limited":
15130
+ return 429;
15131
+ case "upstream_error":
15132
+ return 502;
15133
+ case "server_error":
15134
+ return 500;
15135
+ default:
15136
+ return 400;
15137
+ }
15138
+ }
15139
+
14685
15140
  // src/lib/token-verifier.ts
14686
15141
  import { createHash as createHash3 } from "node:crypto";
14687
15142
  import * as jose from "jose";
@@ -14704,15 +15159,15 @@ function evictFromCache(token) {
14704
15159
  }
14705
15160
  async function verifyCachedOpaqueToken(token, validate, ttlMs) {
14706
15161
  const key = cacheKey(token);
14707
- const cached2 = tokenValidationCache.get(key);
15162
+ const cached3 = tokenValidationCache.get(key);
14708
15163
  const now = Date.now();
14709
- if (cached2 && cached2.expiresAt > now) {
14710
- const tokenExpiresAtMs2 = cached2.authInfo.expiresAt ? cached2.authInfo.expiresAt * 1e3 : void 0;
15164
+ if (cached3 && cached3.expiresAt > now) {
15165
+ const tokenExpiresAtMs2 = cached3.authInfo.expiresAt ? cached3.authInfo.expiresAt * 1e3 : void 0;
14711
15166
  if (!tokenExpiresAtMs2 || tokenExpiresAtMs2 > now) {
14712
- return cached2.authInfo;
15167
+ return cached3.authInfo;
14713
15168
  }
14714
15169
  }
14715
- if (cached2) tokenValidationCache.delete(key);
15170
+ if (cached3) tokenValidationCache.delete(key);
14716
15171
  const authInfo = await validate();
14717
15172
  const tokenExpiresAtMs = authInfo.expiresAt ? authInfo.expiresAt * 1e3 : void 0;
14718
15173
  const cacheExpiresAt = Math.min(Date.now() + ttlMs, tokenExpiresAtMs ?? Number.POSITIVE_INFINITY);
@@ -15008,8 +15463,8 @@ function createClientsStore() {
15008
15463
  }
15009
15464
  return {
15010
15465
  async getClient(clientId) {
15011
- const cached2 = cache.get(clientId);
15012
- if (cached2) return cached2;
15466
+ const cached3 = cache.get(clientId);
15467
+ if (cached3) return cached3;
15013
15468
  if (!supabaseAvailable) return void 0;
15014
15469
  try {
15015
15470
  const supabase = getSupabaseClient();
@@ -15247,46 +15702,6 @@ function createOAuthProvider(options) {
15247
15702
  // src/http.ts
15248
15703
  init_posthog();
15249
15704
 
15250
- // src/lib/discovery-catalog.ts
15251
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15252
- var cached = null;
15253
- function buildDiscoveryCatalog() {
15254
- if (!cached) cached = computeDiscoveryCatalog();
15255
- return cached;
15256
- }
15257
- async function computeDiscoveryCatalog() {
15258
- const schemaByName = /* @__PURE__ */ new Map();
15259
- try {
15260
- const probe = new McpServer({ name: "discovery-probe", version: MCP_VERSION });
15261
- registerAllTools(probe, { skipScreenshots: true });
15262
- const handlers = probe.server._requestHandlers;
15263
- const listHandler = handlers.get("tools/list");
15264
- if (listHandler) {
15265
- const out = await listHandler({ method: "tools/list", params: {} }, {});
15266
- for (const t of out.tools) {
15267
- const props = t.inputSchema?.properties;
15268
- if (props && Object.keys(props).length > 0) {
15269
- schemaByName.set(t.name, {
15270
- type: "object",
15271
- properties: props,
15272
- ...t.inputSchema?.required ? { required: t.inputSchema.required } : {}
15273
- });
15274
- }
15275
- }
15276
- }
15277
- } catch (err) {
15278
- console.error(
15279
- "[mcp] discovery schema build failed; serving names-only catalog:",
15280
- err?.message
15281
- );
15282
- }
15283
- return TOOL_CATALOG.filter((t) => !t.localOnly).map((t) => ({
15284
- name: t.name,
15285
- description: t.description,
15286
- inputSchema: schemaByName.get(t.name) ?? { type: "object", properties: {} }
15287
- }));
15288
- }
15289
-
15290
15705
  // src/lib/origin-policy.ts
15291
15706
  var PRODUCTION_FALLBACK_ORIGINS = [
15292
15707
  "https://socialneuron.com",
@@ -15459,7 +15874,10 @@ app.use((req, res, next) => {
15459
15874
  ipBuckets.set(ip, bucket);
15460
15875
  }
15461
15876
  const elapsed = (now - bucket.lastRefill) / 1e3;
15462
- bucket.tokens = Math.min(IP_RATE_MAX, bucket.tokens + elapsed * IP_RATE_REFILL);
15877
+ bucket.tokens = Math.min(
15878
+ IP_RATE_MAX,
15879
+ bucket.tokens + elapsed * IP_RATE_REFILL
15880
+ );
15463
15881
  bucket.lastRefill = now;
15464
15882
  if (bucket.tokens < 1) {
15465
15883
  const retryAfter = Math.ceil((1 - bucket.tokens) / IP_RATE_REFILL);
@@ -15475,7 +15893,10 @@ app.use((req, res, next) => {
15475
15893
  next();
15476
15894
  });
15477
15895
  app.use((_req, res, next) => {
15478
- res.setHeader("Strict-Transport-Security", "max-age=63072000; includeSubDomains");
15896
+ res.setHeader(
15897
+ "Strict-Transport-Security",
15898
+ "max-age=63072000; includeSubDomains"
15899
+ );
15479
15900
  res.setHeader("X-Content-Type-Options", "nosniff");
15480
15901
  next();
15481
15902
  });
@@ -15497,7 +15918,10 @@ app.use((req, res, next) => {
15497
15918
  "Access-Control-Allow-Headers",
15498
15919
  "Content-Type, Authorization, Mcp-Session-Id, MCP-Protocol-Version"
15499
15920
  );
15500
- res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id, WWW-Authenticate");
15921
+ res.setHeader(
15922
+ "Access-Control-Expose-Headers",
15923
+ "Mcp-Session-Id, WWW-Authenticate"
15924
+ );
15501
15925
  if (req.method === "OPTIONS") {
15502
15926
  res.status(204).end();
15503
15927
  return;
@@ -15576,7 +16000,10 @@ app.use((req, res, next) => {
15576
16000
  async function authenticateRequest(req, res, next) {
15577
16001
  const authHeader = req.headers.authorization;
15578
16002
  if (!authHeader?.startsWith("Bearer ")) {
15579
- res.setHeader("WWW-Authenticate", buildWwwAuthenticateHeader({ issuerUrl: OAUTH_ISSUER_URL }));
16003
+ res.setHeader(
16004
+ "WWW-Authenticate",
16005
+ buildWwwAuthenticateHeader({ issuerUrl: OAUTH_ISSUER_URL })
16006
+ );
15580
16007
  res.status(401).end();
15581
16008
  return;
15582
16009
  }
@@ -15633,8 +16060,8 @@ app.get("/.well-known/mcp/server-card.json", (_req, res) => {
15633
16060
  required: true,
15634
16061
  schemes: ["oauth2"]
15635
16062
  },
15636
- toolCount: TOOL_CATALOG.filter((t) => !t.localOnly).length,
15637
- tools: TOOL_CATALOG.filter((t) => !t.localOnly).map((t) => ({
16063
+ toolCount: TOOL_CATALOG.filter((t) => !t.localOnly && !t.internal).length,
16064
+ tools: TOOL_CATALOG.filter((t) => !t.localOnly && !t.internal).map((t) => ({
15638
16065
  name: t.name,
15639
16066
  description: t.description,
15640
16067
  module: t.module,
@@ -15696,50 +16123,131 @@ app.get("/config", (_req, res) => {
15696
16123
  app.get("/health", (_req, res) => {
15697
16124
  res.json({ status: "ok", version: MCP_VERSION });
15698
16125
  });
15699
- app.get("/health/details", authenticateRequest, (_req, res) => {
15700
- res.json({
15701
- status: "ok",
15702
- version: MCP_VERSION,
15703
- transport: "streamable-http",
15704
- sessions: sessions.size,
15705
- sessionCap: MAX_SESSIONS,
15706
- uptime: Math.floor(process.uptime()),
15707
- memory: Math.round(process.memoryUsage().rss / 1024 / 1024),
15708
- env: NODE_ENV
15709
- });
16126
+ app.get(
16127
+ "/health/details",
16128
+ authenticateRequest,
16129
+ (_req, res) => {
16130
+ res.json({
16131
+ status: "ok",
16132
+ version: MCP_VERSION,
16133
+ transport: "streamable-http",
16134
+ sessions: sessions.size,
16135
+ sessionCap: MAX_SESSIONS,
16136
+ uptime: Math.floor(process.uptime()),
16137
+ memory: Math.round(process.memoryUsage().rss / 1024 / 1024),
16138
+ env: NODE_ENV
16139
+ });
16140
+ }
16141
+ );
16142
+ app.get("/v1/openapi.json", async (_req, res) => {
16143
+ try {
16144
+ const doc = await buildOpenApiDocument();
16145
+ res.setHeader("Cache-Control", "public, max-age=3600");
16146
+ res.json(doc);
16147
+ } catch (err) {
16148
+ res.status(500).json({ error: "openapi_unavailable", message: sanitizeError(err) });
16149
+ }
15710
16150
  });
15711
- app.post("/mcp", (req, res, next) => {
15712
- const body = req.body;
15713
- if (body?.jsonrpc !== "2.0") return next();
15714
- if (body.method === "tools/list") {
15715
- const hasBearer = req.headers.authorization?.startsWith("Bearer ");
15716
- if (hasBearer) {
15717
- next();
15718
- return;
16151
+ app.get("/v1/tools", authenticateRequest, (req, res) => {
16152
+ const scopes = req.auth.scopes;
16153
+ const tools = TOOL_CATALOG.filter((t) => !t.localOnly && !t.internal).map(
16154
+ (t) => {
16155
+ const scope = TOOL_SCOPES[t.name];
16156
+ return {
16157
+ name: t.name,
16158
+ description: t.description,
16159
+ module: t.module,
16160
+ scope,
16161
+ available: scope ? hasScope(scopes, scope) : false
16162
+ };
15719
16163
  }
15720
- buildDiscoveryCatalog().then((tools) => {
15721
- res.json({ jsonrpc: "2.0", id: body.id ?? null, result: { tools } });
15722
- }).catch(() => {
15723
- res.json({
15724
- jsonrpc: "2.0",
15725
- id: body.id ?? null,
15726
- result: {
15727
- tools: TOOL_CATALOG.map((t) => ({
15728
- name: t.name,
15729
- description: t.description,
15730
- inputSchema: { type: "object", properties: {} }
15731
- }))
16164
+ );
16165
+ res.json({ tools, count: tools.length });
16166
+ });
16167
+ app.post(
16168
+ "/v1/tools/:name",
16169
+ authenticateRequest,
16170
+ authenticatedMcpJsonParser,
16171
+ async (req, res) => {
16172
+ const auth = req.auth;
16173
+ const name = String(req.params.name);
16174
+ if (!restToolNames().has(name)) {
16175
+ res.status(404).json({
16176
+ error: {
16177
+ error_type: "not_found",
16178
+ message: `No REST tool named '${name}'.`
15732
16179
  }
15733
16180
  });
15734
- });
15735
- return;
16181
+ return;
16182
+ }
16183
+ const rl = checkRateLimit("read", `rest:${auth.userId}`);
16184
+ if (!rl.allowed) {
16185
+ res.setHeader("Retry-After", String(rl.retryAfter));
16186
+ res.status(429).json({
16187
+ error: { error_type: "rate_limited", message: "Too many requests." }
16188
+ });
16189
+ return;
16190
+ }
16191
+ const args = req.body && typeof req.body === "object" ? req.body : {};
16192
+ try {
16193
+ const result = await requestContext.run(
16194
+ {
16195
+ userId: auth.userId,
16196
+ scopes: auth.scopes,
16197
+ token: auth.token,
16198
+ creditsUsed: 0,
16199
+ assetsGenerated: 0
16200
+ },
16201
+ () => invokeToolRest(name, args)
16202
+ );
16203
+ const status = httpStatusForResult(result);
16204
+ if (result.isError) {
16205
+ res.status(status).json({ error: extractRestError(result), isError: true });
16206
+ } else {
16207
+ res.status(status).json(result);
16208
+ }
16209
+ } catch (err) {
16210
+ res.status(500).json({
16211
+ error: { error_type: "server_error", message: sanitizeError(err) }
16212
+ });
16213
+ }
15736
16214
  }
15737
- if (req.auth) {
15738
- next();
15739
- return;
16215
+ );
16216
+ app.post(
16217
+ "/mcp",
16218
+ (req, res, next) => {
16219
+ const body = req.body;
16220
+ if (body?.jsonrpc !== "2.0") return next();
16221
+ if (body.method === "tools/list") {
16222
+ const hasBearer = req.headers.authorization?.startsWith("Bearer ");
16223
+ if (hasBearer) {
16224
+ next();
16225
+ return;
16226
+ }
16227
+ buildDiscoveryCatalog().then((tools) => {
16228
+ res.json({ jsonrpc: "2.0", id: body.id ?? null, result: { tools } });
16229
+ }).catch(() => {
16230
+ res.json({
16231
+ jsonrpc: "2.0",
16232
+ id: body.id ?? null,
16233
+ result: {
16234
+ tools: TOOL_CATALOG.map((t) => ({
16235
+ name: t.name,
16236
+ description: t.description,
16237
+ inputSchema: { type: "object", properties: {} }
16238
+ }))
16239
+ }
16240
+ });
16241
+ });
16242
+ return;
16243
+ }
16244
+ if (req.auth) {
16245
+ next();
16246
+ return;
16247
+ }
16248
+ authenticateRequest(req, res, next);
15740
16249
  }
15741
- authenticateRequest(req, res, next);
15742
- });
16250
+ );
15743
16251
  app.post("/mcp", async (req, res) => {
15744
16252
  const auth = req.auth;
15745
16253
  const existingSessionId = req.headers["mcp-session-id"];
@@ -15790,7 +16298,7 @@ app.post("/mcp", async (req, res) => {
15790
16298
  });
15791
16299
  return;
15792
16300
  }
15793
- const server = new McpServer2({
16301
+ const server = new McpServer3({
15794
16302
  name: "socialneuron",
15795
16303
  version: MCP_VERSION
15796
16304
  });
@@ -15829,7 +16337,10 @@ app.post("/mcp", async (req, res) => {
15829
16337
  const rawMessage = err instanceof Error ? err.message : "Internal server error";
15830
16338
  console.error(`[MCP HTTP] POST /mcp error: ${rawMessage}`);
15831
16339
  if (!res.headersSent) {
15832
- res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: sanitizeError(err) } });
16340
+ res.status(500).json({
16341
+ jsonrpc: "2.0",
16342
+ error: { code: -32603, message: sanitizeError(err) }
16343
+ });
15833
16344
  }
15834
16345
  }
15835
16346
  });
@@ -15858,38 +16369,49 @@ app.get("/mcp", authenticateRequest, async (req, res) => {
15858
16369
  () => entry.transport.handleRequest(req, res)
15859
16370
  );
15860
16371
  });
15861
- app.delete("/mcp", authenticateRequest, async (req, res) => {
15862
- const sessionId = req.headers["mcp-session-id"];
15863
- if (!sessionId || !sessions.has(sessionId)) {
15864
- res.status(400).json({ error: "Invalid or missing session ID" });
15865
- return;
15866
- }
15867
- const entry = sessions.get(sessionId);
15868
- if (entry.userId !== req.auth.userId) {
15869
- res.status(403).json({ error: "Session belongs to another user" });
15870
- return;
16372
+ app.delete(
16373
+ "/mcp",
16374
+ authenticateRequest,
16375
+ async (req, res) => {
16376
+ const sessionId = req.headers["mcp-session-id"];
16377
+ if (!sessionId || !sessions.has(sessionId)) {
16378
+ res.status(400).json({ error: "Invalid or missing session ID" });
16379
+ return;
16380
+ }
16381
+ const entry = sessions.get(sessionId);
16382
+ if (entry.userId !== req.auth.userId) {
16383
+ res.status(403).json({ error: "Session belongs to another user" });
16384
+ return;
16385
+ }
16386
+ await entry.transport.close();
16387
+ await entry.server.close();
16388
+ sessions.delete(sessionId);
16389
+ res.status(200).json({ status: "session_closed" });
15871
16390
  }
15872
- await entry.transport.close();
15873
- await entry.server.close();
15874
- sessions.delete(sessionId);
15875
- res.status(200).json({ status: "session_closed" });
15876
- });
15877
- app.use((err, _req, res, _next) => {
15878
- console.error("[MCP HTTP] Unhandled Express error:", err.stack || err.message || err);
15879
- if (res.headersSent) return;
15880
- const e = err;
15881
- const status = e.status ?? e.statusCode;
15882
- if (status === 413 || e.type === "entity.too.large") {
15883
- res.status(413).json({
15884
- error: "payload_too_large",
15885
- error_description: "Request body exceeds the allowed JSON limit."
15886
- });
15887
- return;
16391
+ );
16392
+ app.use(
16393
+ (err, _req, res, _next) => {
16394
+ console.error(
16395
+ "[MCP HTTP] Unhandled Express error:",
16396
+ err.stack || err.message || err
16397
+ );
16398
+ if (res.headersSent) return;
16399
+ const e = err;
16400
+ const status = e.status ?? e.statusCode;
16401
+ if (status === 413 || e.type === "entity.too.large") {
16402
+ res.status(413).json({
16403
+ error: "payload_too_large",
16404
+ error_description: "Request body exceeds the allowed JSON limit."
16405
+ });
16406
+ return;
16407
+ }
16408
+ res.status(500).json({ error: "internal_error", error_description: sanitizeError(err) });
15888
16409
  }
15889
- res.status(500).json({ error: "internal_error", error_description: sanitizeError(err) });
15890
- });
16410
+ );
15891
16411
  var httpServer = app.listen(PORT, "0.0.0.0", () => {
15892
- console.log(`[MCP HTTP] Social Neuron MCP Server listening on 0.0.0.0:${PORT}`);
16412
+ console.log(
16413
+ `[MCP HTTP] Social Neuron MCP Server listening on 0.0.0.0:${PORT}`
16414
+ );
15893
16415
  console.log(`[MCP HTTP] Health: http://localhost:${PORT}/health`);
15894
16416
  console.log(`[MCP HTTP] MCP endpoint: ${MCP_SERVER_URL}`);
15895
16417
  console.log(`[MCP HTTP] Environment: ${NODE_ENV}`);