@socialneuron/mcp-server 1.7.5 → 1.7.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ var MCP_VERSION;
14
14
  var init_version = __esm({
15
15
  "src/lib/version.ts"() {
16
16
  "use strict";
17
- MCP_VERSION = "1.7.5";
17
+ MCP_VERSION = "1.7.9";
18
18
  }
19
19
  });
20
20
 
@@ -1273,6 +1273,13 @@ var init_tool_catalog = __esm({
1273
1273
  scope: "mcp:read"
1274
1274
  },
1275
1275
  // carousel (already listed in content section above)
1276
+ // apps (MCP Apps — interactive UI inside the host)
1277
+ {
1278
+ name: "open_content_calendar",
1279
+ description: "Open an interactive drag-drop calendar of the user's scheduled posts inside the host (Claude Desktop / claude.ai). Renders an MCP App; backed by list_recent_posts, schedule_post, find_next_slots \u2014 no new tools needed.",
1280
+ module: "apps",
1281
+ scope: "mcp:read"
1282
+ },
1276
1283
  // recipes
1277
1284
  {
1278
1285
  name: "list_recipes",
@@ -1297,6 +1304,19 @@ var init_tool_catalog = __esm({
1297
1304
  description: "Check the status and progress of a running recipe execution",
1298
1305
  module: "recipes",
1299
1306
  scope: "mcp:read"
1307
+ },
1308
+ // platform connection deep-link flow
1309
+ {
1310
+ name: "start_platform_connection",
1311
+ description: "Mint a single-use deep link for a user to complete platform OAuth in their browser",
1312
+ module: "distribution",
1313
+ scope: "mcp:distribute"
1314
+ },
1315
+ {
1316
+ name: "wait_for_connection",
1317
+ description: "Poll until a platform connection becomes active or timeout",
1318
+ module: "distribution",
1319
+ scope: "mcp:read"
1300
1320
  }
1301
1321
  ];
1302
1322
  }
@@ -3309,9 +3329,9 @@ async function runSetup() {
3309
3329
  console.error(` Key prefix: ${apiKey.substring(0, 12)}...`);
3310
3330
  const configPaths = getConfigPaths();
3311
3331
  let configured = false;
3312
- for (const { path, name } of configPaths) {
3313
- if (configureMcpClient(path)) {
3314
- console.error(` Configured ${name}: ${path}`);
3332
+ for (const { path: path2, name } of configPaths) {
3333
+ if (configureMcpClient(path2)) {
3334
+ console.error(` Configured ${name}: ${path2}`);
3315
3335
  configured = true;
3316
3336
  }
3317
3337
  }
@@ -3978,7 +3998,12 @@ var TOOL_SCOPES = {
3978
3998
  suggest_next_content: "mcp:read",
3979
3999
  // mcp:analytics (digest and anomalies are analytics-scoped)
3980
4000
  generate_performance_digest: "mcp:analytics",
3981
- detect_anomalies: "mcp:analytics"
4001
+ detect_anomalies: "mcp:analytics",
4002
+ // mcp:read (Apps — entry tool for the Content Calendar MCP App; reads recent posts)
4003
+ open_content_calendar: "mcp:read",
4004
+ // platform connection deep-link flow
4005
+ start_platform_connection: "mcp:distribute",
4006
+ wait_for_connection: "mcp:read"
3982
4007
  };
3983
4008
  function hasScope(userScopes, required) {
3984
4009
  if (userScopes.includes(required)) return true;
@@ -5993,7 +6018,7 @@ async function rehostExternalUrl(mediaUrl, projectId) {
5993
6018
  function registerDistributionTools(server2) {
5994
6019
  server2.tool(
5995
6020
  "schedule_post",
5996
- 'Publish or schedule a post to connected social platforms. Check list_connected_accounts first to verify active OAuth for each target platform. For Instagram carousels: use media_type=CAROUSEL_ALBUM with 2-10 media_urls. For YouTube: title is required. schedule_at uses ISO 8601 (e.g. "2026-03-20T14:00:00Z") \u2014 omit to post immediately.',
6021
+ 'Publish or schedule a post to connected social platforms. ALWAYS call `list_connected_accounts` FIRST \u2014 if the target platform is not connected, call `start_platform_connection` to get a one-time browser deep link the user opens to complete the platform OAuth (this is a one-time setup on socialneuron.com, not another OAuth in Claude). After they approve, call `wait_for_connection` and only then call schedule_post. For Instagram carousels: use media_type=CAROUSEL_ALBUM with 2-10 media_urls. For YouTube: title is required. schedule_at uses ISO 8601 (e.g. "2026-03-20T14:00:00Z") \u2014 omit to post immediately.',
5997
6022
  {
5998
6023
  media_url: z3.string().optional().describe(
5999
6024
  "URL of the media file to post. Any public HTTPS URL works \u2014 including ephemeral generator URLs (Replicate, OpenAI, DALL-E). The server persists non-R2 URLs into R2 before posting so scheduled posts and byte-upload platforms (X, LinkedIn, YouTube, Bluesky) do not 404 when the source URL expires. Set auto_rehost=false to skip. Not needed if media_urls, r2_key, or job_id is provided."
@@ -7216,7 +7241,7 @@ function approxBase64Size(raw) {
7216
7241
  function registerMediaTools(server2) {
7217
7242
  server2.tool(
7218
7243
  "upload_media",
7219
- "Upload media to persistent R2 storage. Returns a durable r2_key that can be passed to schedule_post. Three input modes: (1) local file path (stdio mode only), (2) public URL fetched by the server, (3) inline base64 via file_data \u2014 use this from Claude Desktop, Claude Web, or any remote agent that cannot hand the server a filesystem path. Base64 uploads are capped at 10MB decoded; larger files still need stdio + presigned PUT.",
7244
+ "Upload media to persistent R2 storage. Returns a durable r2_key that can be passed to schedule_post. Three input modes: (1) local file path (stdio mode only), (2) public URL fetched by the server, (3) inline base64 via file_data (remote agents, \u226410MB decoded). AGENT ROUTING GUIDE: If the media was produced by another tool here (generate_image, generate_video, create_carousel, etc.), use the returned job_id or r2_key directly with schedule_post \u2014 do NOT download and re-upload. For user-authored files larger than ~1MB, prefer request_upload_session (returns a tokenized Dashboard URL the user uploads through in their browser) so bytes never flow through the agent context. Reserve file_data for small assets (thumbnails, logos, short clips).",
7220
7245
  {
7221
7246
  source: z4.string().optional().describe(
7222
7247
  'Local file path (e.g. "/Users/me/image.png") or public URL (e.g. "https://example.com/photo.jpg"). Leave empty when passing file_data instead.'
@@ -7315,7 +7340,7 @@ function registerMediaTools(server2) {
7315
7340
  content: [
7316
7341
  {
7317
7342
  type: "text",
7318
- text: `file_data exceeds the 10MB base64 cap (got ~${(approxSize / 1024 / 1024).toFixed(1)}MB). For larger files, run the stdio MCP server locally and pass a file path so the server can use presigned PUT upload.`
7343
+ text: `file_data exceeds the 10MB base64 cap (got ~${(approxSize / 1024 / 1024).toFixed(1)}MB). Alternatives, in order of preference: (1) if this media came from another tool here (generate_image/video, create_carousel), pass its job_id or r2_key directly to schedule_post \u2014 do not re-upload. (2) for user-authored files, call request_upload_session to get a tokenized Dashboard URL where the user uploads directly to R2 in their browser. (3) for stdio/local mode, pass a filesystem path via \`source\` so the server can stream and use presigned PUT.`
7319
7344
  }
7320
7345
  ],
7321
7346
  isError: true
@@ -8078,7 +8103,7 @@ function registerBrandTools(server2) {
8078
8103
  );
8079
8104
  server2.tool(
8080
8105
  "save_brand_profile",
8081
- "Persist a brand profile as the active profile for a project.",
8106
+ "Save (or replace) the active brand profile for a project \u2014 voice, target audience, content pillars, claims, etc. Use after extract_brand has produced a draft AND the user has reviewed it, or when the user explicitly edits the profile. brand_context is the full profile payload from extract_brand or get_brand_profile. project_id defaults to the active project context. Overwrites the previous active profile (one per project) \u2014 pass the complete profile, no merge semantics. Use change_summary to leave an audit trail.",
8082
8107
  {
8083
8108
  project_id: z6.string().uuid().optional().describe("Project ID. Defaults to active project context."),
8084
8109
  brand_context: z6.record(z6.string(), z6.unknown()).describe("Brand context payload to save to brand_profiles.brand_context."),
@@ -9663,7 +9688,7 @@ function registerCommentsTools(server2) {
9663
9688
  );
9664
9689
  server2.tool(
9665
9690
  "post_comment",
9666
- "Post a new top-level comment on a YouTube video.",
9691
+ "Post a new top-level comment on a YouTube video, authored as the connected channel. Use for proactive engagement on your own videos. For replies to existing comments use reply_to_comment instead \u2014 this tool only creates top-level comments. video_id comes from list_recent_posts (platform_post_id field) or any YouTube URL (the v= parameter, 11 chars). Subject to YouTube anti-spam rate limits; calls return rate_limited if exceeded.",
9667
9692
  {
9668
9693
  video_id: z11.string().describe("The YouTube video ID to comment on."),
9669
9694
  text: z11.string().min(1).describe("The comment text."),
@@ -9734,7 +9759,7 @@ function registerCommentsTools(server2) {
9734
9759
  );
9735
9760
  server2.tool(
9736
9761
  "moderate_comment",
9737
- "Moderate a YouTube comment by setting its status to published or rejected.",
9762
+ 'Moderate a YouTube comment on your channel \u2014 set status to "published" (approve) or "rejected" (hide from public view but kept in moderation queue). Use after list_comments surfaces a comment that needs action. For permanent removal use delete_comment instead. comment_id comes from list_comments results.',
9738
9763
  {
9739
9764
  comment_id: z11.string().describe("The comment ID to moderate."),
9740
9765
  moderation_status: z11.enum(["published", "rejected"]).describe('"published" to approve, "rejected" to hide.'),
@@ -11667,7 +11692,7 @@ ${rawText.slice(0, 1e3)}`
11667
11692
  );
11668
11693
  server2.tool(
11669
11694
  "get_content_plan",
11670
- "Retrieve a persisted content plan by ID.",
11695
+ "Load a persisted content plan by its UUID \u2014 returns the full plan including all posts, scheduling status, and approval state. Use to inspect a plan before update_content_plan or schedule_content_plan. plan_id comes from save_content_plan, plan_content_week (when persisted), or list_plan_approvals. For just the approval state, list_plan_approvals is cheaper.",
11671
11696
  {
11672
11697
  plan_id: z20.string().uuid().describe("Persisted content plan ID"),
11673
11698
  response_format: z20.enum(["text", "json"]).default("json")
@@ -11721,7 +11746,7 @@ ${rawText.slice(0, 1e3)}`
11721
11746
  );
11722
11747
  server2.tool(
11723
11748
  "update_content_plan",
11724
- "Update individual posts in a persisted content plan.",
11749
+ "Edit individual posts in a persisted content plan \u2014 change caption, title, hashtags, hook, or angle. Use after get_content_plan when the user wants to revise drafts before scheduling. Each post_updates entry must include post_id from the loaded plan; only the fields you pass get updated, others stay as-is. Does NOT trigger publishing \u2014 call schedule_content_plan separately when ready.",
11725
11750
  {
11726
11751
  plan_id: z20.string().uuid(),
11727
11752
  post_updates: z20.array(
@@ -11864,7 +11889,7 @@ function asEnvelope17(data) {
11864
11889
  function registerPlanApprovalTools(server2) {
11865
11890
  server2.tool(
11866
11891
  "create_plan_approvals",
11867
- "Create pending approval rows for each post in a content plan.",
11892
+ 'Create pending approval rows for each post in a content plan \u2014 one row per post, status="pending". Use after submit_content_plan_for_approval to materialize the approval queue. Each entry in posts becomes a row that respond_plan_approval can later approve, reject, or edit. Idempotent on (plan_id, post_id) \u2014 calling twice with the same posts is a no-op for already-existing rows. Returns IDs of created items for use with list_plan_approvals.',
11868
11893
  {
11869
11894
  plan_id: z21.string().uuid().describe("Content plan ID"),
11870
11895
  posts: z21.array(
@@ -11944,7 +11969,7 @@ function registerPlanApprovalTools(server2) {
11944
11969
  );
11945
11970
  server2.tool(
11946
11971
  "list_plan_approvals",
11947
- "List MCP-native approval items for a specific content plan.",
11972
+ "List approval items for a content plan, optionally filtered by status (pending / approved / rejected / edited). Use to check what needs review before scheduling, or to audit decisions after the fact. plan_id comes from get_content_plan or save_content_plan. For a single item's full state, get the plan via get_content_plan instead \u2014 that includes per-post approval data inline.",
11948
11973
  {
11949
11974
  plan_id: z21.string().uuid().describe("Content plan ID"),
11950
11975
  status: z21.enum(["pending", "approved", "rejected", "edited"]).optional(),
@@ -12004,7 +12029,7 @@ function registerPlanApprovalTools(server2) {
12004
12029
  );
12005
12030
  server2.tool(
12006
12031
  "respond_plan_approval",
12007
- "Approve, reject, or edit a pending plan approval item.",
12032
+ 'Approve, reject, or edit a single pending plan approval item. Use to act on items surfaced by list_plan_approvals. decision="edited" REQUIRES edited_post containing the modified post fields \u2014 passing "edited" without edited_post returns an error. Once decided, an item cannot be re-decided (immutable transition). reason is optional but recommended for "rejected" or "edited" to leave a paper trail. After all items are decided, schedule_content_plan publishes only the approved (and edited) ones.',
12008
12033
  {
12009
12034
  approval_id: z21.string().uuid().describe("Approval item ID"),
12010
12035
  decision: z21.enum(["approved", "rejected", "edited"]),
@@ -13833,11 +13858,11 @@ function hexToLab(hex) {
13833
13858
  const lb = srgbToLinear(b);
13834
13859
  const x = lr * 0.4124564 + lg * 0.3575761 + lb * 0.1804375;
13835
13860
  const y = lr * 0.2126729 + lg * 0.7151522 + lb * 0.072175;
13836
- const z29 = lr * 0.0193339 + lg * 0.119192 + lb * 0.9503041;
13861
+ const z31 = lr * 0.0193339 + lg * 0.119192 + lb * 0.9503041;
13837
13862
  const f = (t) => t > 8856e-6 ? Math.cbrt(t) : 7.787 * t + 16 / 116;
13838
13863
  const fx = f(x / 0.95047);
13839
13864
  const fy = f(y / 1);
13840
- const fz = f(z29 / 1.08883);
13865
+ const fz = f(z31 / 1.08883);
13841
13866
  return { L: 116 * fy - 16, a: 500 * (fx - fy), b: 200 * (fy - fz) };
13842
13867
  }
13843
13868
  function deltaE2000(lab1, lab2) {
@@ -14664,6 +14689,345 @@ function registerCarouselTools(server2) {
14664
14689
  );
14665
14690
  }
14666
14691
 
14692
+ // src/apps/content-calendar.ts
14693
+ init_edge_function();
14694
+ import {
14695
+ registerAppTool,
14696
+ registerAppResource,
14697
+ RESOURCE_MIME_TYPE
14698
+ } from "@modelcontextprotocol/ext-apps/server";
14699
+ import { z as z28 } from "zod";
14700
+ import fs from "node:fs/promises";
14701
+ import path from "node:path";
14702
+ var CALENDAR_URI = "ui://content-calendar/mcp-app.html";
14703
+ function startOfCurrentWeekMonday() {
14704
+ const now = /* @__PURE__ */ new Date();
14705
+ const day = now.getDay();
14706
+ const monday = new Date(now);
14707
+ monday.setUTCDate(now.getUTCDate() - day + (day === 0 ? -6 : 1));
14708
+ return monday.toISOString().split("T")[0];
14709
+ }
14710
+ function registerContentCalendarApp(server2) {
14711
+ registerAppTool(
14712
+ server2,
14713
+ "open_content_calendar",
14714
+ {
14715
+ title: "Content Calendar",
14716
+ description: "Open an interactive drag-drop calendar showing the user's scheduled posts for the current week. Users can reschedule via drag, filter by platform, drill into any post, or quick-create a new post. Backed by list_recent_posts, schedule_post, and find_next_slots \u2014 no new tools needed.",
14717
+ inputSchema: {
14718
+ start_date: z28.string().optional().describe("ISO date for the week start (YYYY-MM-DD); defaults to the current week's Monday.")
14719
+ },
14720
+ _meta: {
14721
+ ui: {
14722
+ resourceUri: CALENDAR_URI,
14723
+ csp: {
14724
+ "img-src": ["'self'", "https://*.r2.cloudflarestorage.com", "data:"],
14725
+ "connect-src": ["'self'"]
14726
+ }
14727
+ }
14728
+ }
14729
+ },
14730
+ async ({ start_date }, extra) => {
14731
+ const userScopes = extra.authInfo?.scopes ?? [];
14732
+ const fromDate = start_date ?? startOfCurrentWeekMonday();
14733
+ const { data: result, error } = await callEdgeFunction(
14734
+ "mcp-data",
14735
+ {
14736
+ action: "recent-posts",
14737
+ days: 14,
14738
+ limit: 50
14739
+ },
14740
+ { timeoutMs: 15e3 }
14741
+ );
14742
+ if (error || !result?.success) {
14743
+ return {
14744
+ content: [
14745
+ {
14746
+ type: "text",
14747
+ text: `Failed to load posts: ${error || result?.error || "Unknown error"}`
14748
+ }
14749
+ ],
14750
+ isError: true
14751
+ };
14752
+ }
14753
+ const posts = (result.posts ?? []).filter((p) => {
14754
+ const ts = p.scheduled_at ?? p.published_at ?? p.created_at;
14755
+ if (!ts) return false;
14756
+ return ts.split("T")[0] >= fromDate;
14757
+ });
14758
+ return {
14759
+ content: [
14760
+ {
14761
+ type: "text",
14762
+ text: JSON.stringify({ posts, scopes: userScopes })
14763
+ }
14764
+ ]
14765
+ };
14766
+ }
14767
+ );
14768
+ registerAppResource(
14769
+ server2,
14770
+ CALENDAR_URI,
14771
+ CALENDAR_URI,
14772
+ { mimeType: RESOURCE_MIME_TYPE },
14773
+ async () => {
14774
+ const htmlPath = path.join(process.cwd(), "apps/content-calendar/dist/mcp-app.html");
14775
+ try {
14776
+ const html = await fs.readFile(htmlPath, "utf-8");
14777
+ return {
14778
+ contents: [{ uri: CALENDAR_URI, mimeType: RESOURCE_MIME_TYPE, text: html }]
14779
+ };
14780
+ } catch (err) {
14781
+ const errorHtml = `<!DOCTYPE html>
14782
+ <html><head><title>Content Calendar \u2014 unavailable</title></head>
14783
+ <body style="font-family:sans-serif;padding:24px;color:#444;">
14784
+ <h2>Content Calendar app bundle missing</h2>
14785
+ <p>The server registered <code>open_content_calendar</code> but
14786
+ <code>apps/content-calendar/dist/mcp-app.html</code> is not built.
14787
+ Run <code>npm run build:app</code> in the mcp-server directory and redeploy.</p>
14788
+ <p style="color:#999;font-size:12px;">${err.message}</p>
14789
+ </body></html>`;
14790
+ return {
14791
+ contents: [{ uri: CALENDAR_URI, mimeType: RESOURCE_MIME_TYPE, text: errorHtml }]
14792
+ };
14793
+ }
14794
+ }
14795
+ );
14796
+ }
14797
+
14798
+ // src/tools/connections.ts
14799
+ init_edge_function();
14800
+ import { z as z29 } from "zod";
14801
+ init_supabase();
14802
+ var PLATFORM_ENUM4 = [
14803
+ "youtube",
14804
+ "tiktok",
14805
+ "instagram",
14806
+ "twitter",
14807
+ "linkedin",
14808
+ "facebook",
14809
+ "threads",
14810
+ "bluesky",
14811
+ "shopify",
14812
+ "etsy"
14813
+ ];
14814
+ function findActiveAccount(accounts, platform3) {
14815
+ const target = platform3.toLowerCase();
14816
+ return accounts.find((a) => a.platform.toLowerCase() === target && a.status === "active") ?? null;
14817
+ }
14818
+ function registerConnectionTools(server2) {
14819
+ server2.tool(
14820
+ "start_platform_connection",
14821
+ "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.",
14822
+ {
14823
+ platform: z29.enum(PLATFORM_ENUM4).describe("Platform to connect. Lower-case: instagram, tiktok, youtube, etc."),
14824
+ response_format: z29.enum(["text", "json"]).optional().describe("Response format. Default: text.")
14825
+ },
14826
+ async ({ platform: platform3, response_format }) => {
14827
+ const format = response_format ?? "text";
14828
+ const startedAt = Date.now();
14829
+ const rl = checkRateLimit("read", `start_platform_connection:${platform3}`);
14830
+ if (!rl.allowed) {
14831
+ await logMcpToolInvocation({
14832
+ toolName: "start_platform_connection",
14833
+ status: "rate_limited",
14834
+ durationMs: Date.now() - startedAt,
14835
+ details: { retryAfter: rl.retryAfter, platform: platform3 }
14836
+ });
14837
+ return {
14838
+ content: [
14839
+ {
14840
+ type: "text",
14841
+ text: `Rate limit exceeded. Retry in ~${rl.retryAfter}s.`
14842
+ }
14843
+ ],
14844
+ isError: true
14845
+ };
14846
+ }
14847
+ const { data, error } = await callEdgeFunction("mcp-data", { action: "mint-connection-nonce", platform: platform3 }, { timeoutMs: 1e4 });
14848
+ if (error || !data?.success || !data.deep_link) {
14849
+ const errMsg = error ?? data?.error ?? "Unknown error";
14850
+ await logMcpToolInvocation({
14851
+ toolName: "start_platform_connection",
14852
+ status: "error",
14853
+ durationMs: Date.now() - startedAt,
14854
+ details: { error: errMsg, platform: platform3 }
14855
+ });
14856
+ return {
14857
+ content: [
14858
+ {
14859
+ type: "text",
14860
+ text: `Failed to start ${platform3} connection: ${errMsg}`
14861
+ }
14862
+ ],
14863
+ isError: true
14864
+ };
14865
+ }
14866
+ await logMcpToolInvocation({
14867
+ toolName: "start_platform_connection",
14868
+ status: "success",
14869
+ durationMs: Date.now() - startedAt,
14870
+ details: { platform: data.platform, expires_at: data.expires_at }
14871
+ });
14872
+ if (format === "json") {
14873
+ return {
14874
+ content: [
14875
+ {
14876
+ type: "text",
14877
+ text: JSON.stringify(
14878
+ {
14879
+ platform: data.platform,
14880
+ deep_link: data.deep_link,
14881
+ expires_at: data.expires_at,
14882
+ next_step: "Open deep_link in a browser, approve on the platform, then call wait_for_connection."
14883
+ },
14884
+ null,
14885
+ 2
14886
+ )
14887
+ }
14888
+ ],
14889
+ isError: false
14890
+ };
14891
+ }
14892
+ return {
14893
+ content: [
14894
+ {
14895
+ type: "text",
14896
+ text: [
14897
+ `${data.platform} connection ready.`,
14898
+ "",
14899
+ 'Ask the user to open this link in a browser and click "Connect" on the platform:',
14900
+ ` ${data.deep_link}`,
14901
+ "",
14902
+ `Link expires at: ${data.expires_at} (~2 minutes).`,
14903
+ "",
14904
+ "After they approve, call `wait_for_connection` with the same platform to confirm.",
14905
+ "This is a one-time browser setup \u2014 not another OAuth flow inside Claude."
14906
+ ].join("\n")
14907
+ }
14908
+ ],
14909
+ isError: false
14910
+ };
14911
+ }
14912
+ );
14913
+ server2.tool(
14914
+ "wait_for_connection",
14915
+ "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.",
14916
+ {
14917
+ platform: z29.enum(PLATFORM_ENUM4).describe("Platform to wait for."),
14918
+ timeout_s: z29.number().min(5).max(600).optional().describe("How long to wait, in seconds. Default 120."),
14919
+ poll_interval_s: z29.number().min(2).max(30).optional().describe("Poll interval in seconds. Default 5."),
14920
+ response_format: z29.enum(["text", "json"]).optional().describe("Response format. Default: text.")
14921
+ },
14922
+ async ({ platform: platform3, timeout_s, poll_interval_s, response_format }) => {
14923
+ const format = response_format ?? "text";
14924
+ const startedAt = Date.now();
14925
+ const timeoutMs = (timeout_s ?? 120) * 1e3;
14926
+ const intervalMs = (poll_interval_s ?? 5) * 1e3;
14927
+ const deadline = startedAt + timeoutMs;
14928
+ const rl = checkRateLimit("read", `wait_for_connection:${platform3}`);
14929
+ if (!rl.allowed) {
14930
+ return {
14931
+ content: [
14932
+ {
14933
+ type: "text",
14934
+ text: `Rate limit exceeded. Retry in ~${rl.retryAfter}s.`
14935
+ }
14936
+ ],
14937
+ isError: true
14938
+ };
14939
+ }
14940
+ let attempts = 0;
14941
+ while (Date.now() < deadline) {
14942
+ attempts++;
14943
+ const { data, error } = await callEdgeFunction("mcp-data", { action: "connected-accounts" }, { timeoutMs: 1e4 });
14944
+ if (!error && data?.success) {
14945
+ const found = findActiveAccount(data.accounts ?? [], platform3);
14946
+ if (found) {
14947
+ await logMcpToolInvocation({
14948
+ toolName: "wait_for_connection",
14949
+ status: "success",
14950
+ durationMs: Date.now() - startedAt,
14951
+ details: { platform: platform3, attempts, found: true }
14952
+ });
14953
+ if (format === "json") {
14954
+ return {
14955
+ content: [
14956
+ {
14957
+ type: "text",
14958
+ text: JSON.stringify(
14959
+ {
14960
+ connected: true,
14961
+ platform: found.platform,
14962
+ account_id: found.id,
14963
+ username: found.username,
14964
+ connected_at: found.created_at,
14965
+ attempts
14966
+ },
14967
+ null,
14968
+ 2
14969
+ )
14970
+ }
14971
+ ],
14972
+ isError: false
14973
+ };
14974
+ }
14975
+ return {
14976
+ content: [
14977
+ {
14978
+ type: "text",
14979
+ text: [
14980
+ `${found.platform} is connected.`,
14981
+ `Account: ${found.username || "(unnamed)"} (id=${found.id})`,
14982
+ `Detected after ${attempts} poll(s) in ${((Date.now() - startedAt) / 1e3).toFixed(1)}s.`,
14983
+ "Ready to call `schedule_post`."
14984
+ ].join("\n")
14985
+ }
14986
+ ],
14987
+ isError: false
14988
+ };
14989
+ }
14990
+ }
14991
+ const remaining = deadline - Date.now();
14992
+ if (remaining <= 0) break;
14993
+ await new Promise((resolve3) => setTimeout(resolve3, Math.min(intervalMs, remaining)));
14994
+ }
14995
+ await logMcpToolInvocation({
14996
+ toolName: "wait_for_connection",
14997
+ status: "error",
14998
+ durationMs: Date.now() - startedAt,
14999
+ details: { platform: platform3, attempts, found: false, reason: "timeout" }
15000
+ });
15001
+ 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.`;
15002
+ if (format === "json") {
15003
+ return {
15004
+ content: [
15005
+ {
15006
+ type: "text",
15007
+ text: JSON.stringify(
15008
+ {
15009
+ connected: false,
15010
+ platform: platform3,
15011
+ attempts,
15012
+ timed_out: true,
15013
+ message
15014
+ },
15015
+ null,
15016
+ 2
15017
+ )
15018
+ }
15019
+ ],
15020
+ isError: true
15021
+ };
15022
+ }
15023
+ return {
15024
+ content: [{ type: "text", text: message }],
15025
+ isError: true
15026
+ };
15027
+ }
15028
+ );
15029
+ }
15030
+
14667
15031
  // src/lib/register-tools.ts
14668
15032
  function applyScopeEnforcement(server2, scopeResolver) {
14669
15033
  const originalTool = server2.tool.bind(server2);
@@ -14693,7 +15057,7 @@ function applyScopeEnforcement(server2, scopeResolver) {
14693
15057
  content: [
14694
15058
  {
14695
15059
  type: "text",
14696
- text: `Permission denied: '${name}' requires scope '${requiredScope}'. Generate a new key with the required scope at https://socialneuron.com/settings/developer`
15060
+ text: `Permission denied: '${name}' requires scope '${requiredScope}'. Your scopes: [${userScopes.join(", ")}]. API-key users: regenerate your key with this scope at https://socialneuron.com/settings/developer. OAuth users (Claude Custom Connector): this scope is not enabled for your plan tier.`
14697
15061
  }
14698
15062
  ],
14699
15063
  isError: true
@@ -14769,21 +15133,25 @@ function registerAllTools(server2, options) {
14769
15133
  registerDigestTools(server2);
14770
15134
  registerBrandRuntimeTools(server2);
14771
15135
  registerCarouselTools(server2);
15136
+ registerConnectionTools(server2);
15137
+ if (!options?.skipApps) {
15138
+ registerContentCalendarApp(server2);
15139
+ }
14772
15140
  applyAnnotations(server2);
14773
15141
  }
14774
15142
 
14775
15143
  // src/prompts.ts
14776
- import { z as z28 } from "zod";
15144
+ import { z as z30 } from "zod";
14777
15145
  function registerPrompts(server2) {
14778
15146
  server2.prompt(
14779
15147
  "create_weekly_content_plan",
14780
15148
  "Generate a full week of social media content (7 days, multiple platforms). Returns a structured plan with topics, formats, and posting times.",
14781
15149
  {
14782
- niche: z28.string().describe('Your content niche or industry (e.g., "fitness coaching", "SaaS marketing")'),
14783
- platforms: z28.string().optional().describe(
15150
+ niche: z30.string().describe('Your content niche or industry (e.g., "fitness coaching", "SaaS marketing")'),
15151
+ platforms: z30.string().optional().describe(
14784
15152
  'Comma-separated platforms to target (default: "YouTube, Instagram, TikTok, LinkedIn")'
14785
15153
  ),
14786
- tone: z28.string().optional().describe('Brand tone of voice (e.g., "professional", "casual", "bold and edgy")')
15154
+ tone: z30.string().optional().describe('Brand tone of voice (e.g., "professional", "casual", "bold and edgy")')
14787
15155
  },
14788
15156
  ({ niche, platforms, tone }) => {
14789
15157
  const targetPlatforms = platforms || "YouTube, Instagram, TikTok, LinkedIn";
@@ -14825,8 +15193,8 @@ After building the plan, use \`save_content_plan\` to save it.`
14825
15193
  "analyze_top_content",
14826
15194
  "Analyze your best-performing posts to identify patterns and replicate success. Returns insights on hooks, formats, timing, and topics that resonate.",
14827
15195
  {
14828
- timeframe: z28.string().optional().describe('Analysis period (default: "30 days"). E.g., "7 days", "90 days"'),
14829
- platform: z28.string().optional().describe('Filter to a specific platform (e.g., "youtube", "instagram")')
15196
+ timeframe: z30.string().optional().describe('Analysis period (default: "30 days"). E.g., "7 days", "90 days"'),
15197
+ platform: z30.string().optional().describe('Filter to a specific platform (e.g., "youtube", "instagram")')
14830
15198
  },
14831
15199
  ({ timeframe, platform: platform3 }) => {
14832
15200
  const period = timeframe || "30 days";
@@ -14863,10 +15231,10 @@ Format as a clear, actionable performance report.`
14863
15231
  "repurpose_content",
14864
15232
  "Take one piece of content and transform it into 8-10 pieces across multiple platforms and formats.",
14865
15233
  {
14866
- source: z28.string().describe(
15234
+ source: z30.string().describe(
14867
15235
  "The source content to repurpose \u2014 a URL, transcript, or the content text itself"
14868
15236
  ),
14869
- target_platforms: z28.string().optional().describe(
15237
+ target_platforms: z30.string().optional().describe(
14870
15238
  'Comma-separated target platforms (default: "Twitter, LinkedIn, Instagram, TikTok, YouTube")'
14871
15239
  )
14872
15240
  },
@@ -14908,9 +15276,9 @@ For each piece, include the platform, format, character count, and suggested pos
14908
15276
  "setup_brand_voice",
14909
15277
  "Define or refine your brand voice profile so all generated content stays on-brand. Walks through tone, audience, values, and style.",
14910
15278
  {
14911
- brand_name: z28.string().describe("Your brand or business name"),
14912
- industry: z28.string().optional().describe('Your industry or niche (e.g., "B2B SaaS", "fitness coaching")'),
14913
- website: z28.string().optional().describe("Your website URL for context")
15279
+ brand_name: z30.string().describe("Your brand or business name"),
15280
+ industry: z30.string().optional().describe('Your industry or niche (e.g., "B2B SaaS", "fitness coaching")'),
15281
+ website: z30.string().optional().describe("Your website URL for context")
14914
15282
  },
14915
15283
  ({ brand_name, industry, website }) => {
14916
15284
  const industryContext = industry ? ` in the ${industry} space` : "";
@@ -15424,7 +15792,7 @@ var server = new McpServer({
15424
15792
  version: MCP_VERSION
15425
15793
  });
15426
15794
  applyScopeEnforcement(server, getAuthenticatedScopes);
15427
- registerAllTools(server);
15795
+ registerAllTools(server, { skipApps: true });
15428
15796
  registerPrompts(server);
15429
15797
  registerResources(server);
15430
15798
  async function shutdown() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@socialneuron/mcp-server",
3
- "version": "1.7.5",
3
+ "version": "1.7.9",
4
4
  "description": "MCP server for Social Neuron - AI content creation platform",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -57,7 +57,8 @@
57
57
  "build": "esbuild src/http.ts --bundle --platform=node --target=es2022 --format=esm --outfile=dist/http.js --packages=external",
58
58
  "build:stdio": "esbuild src/index.ts --bundle --platform=node --target=es2022 --format=esm --outfile=dist/index.js --banner:js='#!/usr/bin/env node' --packages=external && chmod +x dist/index.js",
59
59
  "build:lock": "node scripts/build-tools-lock.mjs",
60
- "build:all": "npm run build:stdio && npm run build && npm run build:lock",
60
+ "build:app": "cd apps/content-calendar && (test -d node_modules || npm install --no-audit --no-fund) && npm run build",
61
+ "build:all": "npm run build:stdio && npm run build && npm run build:lock && npm run build:app",
61
62
  "verify:lock": "node scripts/verify-tools-lock.mjs",
62
63
  "lint:tools": "node scripts/lint-tool-descriptions.mjs",
63
64
  "start": "node dist/http.js",
@@ -70,8 +71,9 @@
70
71
  "prepublishOnly": "npm run clean && npm run build:all && npm test"
71
72
  },
72
73
  "dependencies": {
74
+ "@modelcontextprotocol/ext-apps": "1.7.0",
73
75
  "@modelcontextprotocol/sdk": "^1.27.1",
74
- "@supabase/supabase-js": "2.103.1",
76
+ "@supabase/supabase-js": "2.104.0",
75
77
  "cors": "^2.8.6",
76
78
  "express": "^5.1.0",
77
79
  "express-rate-limit": "^8.3.2",