@socialneuron/mcp-server 1.7.4 → 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.4";
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;
@@ -5747,7 +5772,7 @@ var ERROR_PATTERNS = [
5747
5772
  // Generic sensitive patterns (API keys, URLs with secrets)
5748
5773
  [/[a-z0-9]{32,}.*key|Bearer [a-zA-Z0-9._-]+/i, "An internal error occurred. Please try again."]
5749
5774
  ];
5750
- function sanitizeError2(error) {
5775
+ function sanitizeError(error) {
5751
5776
  const msg = error instanceof Error ? error.message : typeof error === "string" ? error : "Unknown error";
5752
5777
  if (process.env.NODE_ENV !== "production") {
5753
5778
  console.error("[Error]", msg);
@@ -5760,6 +5785,171 @@ function sanitizeError2(error) {
5760
5785
  return "An unexpected error occurred. Please try again.";
5761
5786
  }
5762
5787
 
5788
+ // src/lib/ssrf.ts
5789
+ var BLOCKED_IP_PATTERNS = [
5790
+ // IPv4 localhost/loopback
5791
+ /^127\./,
5792
+ /^0\./,
5793
+ // IPv4 private ranges (RFC 1918)
5794
+ /^10\./,
5795
+ /^172\.(1[6-9]|2[0-9]|3[0-1])\./,
5796
+ /^192\.168\./,
5797
+ // IPv4 link-local
5798
+ /^169\.254\./,
5799
+ // Cloud metadata endpoint (AWS, GCP, Azure)
5800
+ /^169\.254\.169\.254$/,
5801
+ // IPv4 broadcast
5802
+ /^255\./,
5803
+ // Shared address space (RFC 6598)
5804
+ /^100\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])\./
5805
+ ];
5806
+ var BLOCKED_IPV6_PATTERNS = [
5807
+ /^::1$/i,
5808
+ // loopback
5809
+ /^::$/i,
5810
+ // unspecified
5811
+ /^fe[89ab][0-9a-f]:/i,
5812
+ // link-local fe80::/10
5813
+ /^fc[0-9a-f]:/i,
5814
+ // unique local fc00::/7
5815
+ /^fd[0-9a-f]:/i,
5816
+ // unique local fc00::/7
5817
+ /^::ffff:127\./i,
5818
+ // IPv4-mapped localhost
5819
+ /^::ffff:(0|10|127|169\.254|172\.(1[6-9]|2[0-9]|3[0-1])|192\.168)\./i
5820
+ // IPv4-mapped private
5821
+ ];
5822
+ var BLOCKED_HOSTNAMES = [
5823
+ "localhost",
5824
+ "localhost.localdomain",
5825
+ "local",
5826
+ "127.0.0.1",
5827
+ "0.0.0.0",
5828
+ "[::1]",
5829
+ "[::ffff:127.0.0.1]",
5830
+ // Cloud metadata endpoints
5831
+ "metadata.google.internal",
5832
+ "metadata.goog",
5833
+ "instance-data",
5834
+ "instance-data.ec2.internal"
5835
+ ];
5836
+ var ALLOWED_PROTOCOLS = ["http:", "https:"];
5837
+ var BLOCKED_PORTS = [22, 23, 25, 110, 143, 445, 3306, 5432, 6379, 27017, 11211];
5838
+ function isBlockedIP(ip) {
5839
+ const normalized = ip.replace(/^\[/, "").replace(/\]$/, "");
5840
+ if (normalized.includes(":")) {
5841
+ return BLOCKED_IPV6_PATTERNS.some((pattern) => pattern.test(normalized));
5842
+ }
5843
+ return BLOCKED_IP_PATTERNS.some((pattern) => pattern.test(normalized));
5844
+ }
5845
+ function isBlockedHostname(hostname) {
5846
+ return BLOCKED_HOSTNAMES.includes(hostname.toLowerCase());
5847
+ }
5848
+ function isIPAddress(hostname) {
5849
+ const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/;
5850
+ const ipv6Pattern = /^\[?[a-fA-F0-9:]+\]?$/;
5851
+ return ipv4Pattern.test(hostname) || ipv6Pattern.test(hostname);
5852
+ }
5853
+ async function validateUrlForSSRF(urlString) {
5854
+ try {
5855
+ const url = new URL(urlString);
5856
+ if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {
5857
+ return {
5858
+ isValid: false,
5859
+ error: `Invalid protocol: ${url.protocol}. Only HTTP and HTTPS are allowed.`
5860
+ };
5861
+ }
5862
+ if (url.username || url.password) {
5863
+ return {
5864
+ isValid: false,
5865
+ error: "URLs with embedded credentials are not allowed."
5866
+ };
5867
+ }
5868
+ const hostname = url.hostname.toLowerCase();
5869
+ if (isBlockedHostname(hostname)) {
5870
+ return {
5871
+ isValid: false,
5872
+ error: "Access to internal/localhost addresses is not allowed."
5873
+ };
5874
+ }
5875
+ if (isIPAddress(hostname) && isBlockedIP(hostname)) {
5876
+ return {
5877
+ isValid: false,
5878
+ error: "Access to private/internal IP addresses is not allowed."
5879
+ };
5880
+ }
5881
+ const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80;
5882
+ if (BLOCKED_PORTS.includes(port)) {
5883
+ return {
5884
+ isValid: false,
5885
+ error: `Access to port ${port} is not allowed.`
5886
+ };
5887
+ }
5888
+ let resolvedIP;
5889
+ if (!isIPAddress(hostname)) {
5890
+ try {
5891
+ const dns = await import("node:dns");
5892
+ const resolver = new dns.promises.Resolver();
5893
+ const resolvedIPs = [];
5894
+ try {
5895
+ const aRecords = await resolver.resolve4(hostname);
5896
+ resolvedIPs.push(...aRecords);
5897
+ } catch {
5898
+ }
5899
+ try {
5900
+ const aaaaRecords = await resolver.resolve6(hostname);
5901
+ resolvedIPs.push(...aaaaRecords);
5902
+ } catch {
5903
+ }
5904
+ if (resolvedIPs.length === 0) {
5905
+ return {
5906
+ isValid: false,
5907
+ error: "DNS resolution failed: hostname did not resolve to any address."
5908
+ };
5909
+ }
5910
+ for (const ip of resolvedIPs) {
5911
+ if (isBlockedIP(ip)) {
5912
+ return {
5913
+ isValid: false,
5914
+ error: "Hostname resolves to a private/internal IP address."
5915
+ };
5916
+ }
5917
+ }
5918
+ resolvedIP = resolvedIPs[0];
5919
+ } catch {
5920
+ return {
5921
+ isValid: false,
5922
+ error: "DNS resolution failed. Cannot verify hostname safety."
5923
+ };
5924
+ }
5925
+ }
5926
+ return { isValid: true, sanitizedUrl: url.toString(), resolvedIP };
5927
+ } catch (error) {
5928
+ return {
5929
+ isValid: false,
5930
+ error: `Invalid URL format: ${error instanceof Error ? error.message : "Unknown error"}`
5931
+ };
5932
+ }
5933
+ }
5934
+ function quickSSRFCheck(urlString) {
5935
+ try {
5936
+ const url = new URL(urlString);
5937
+ if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {
5938
+ return { isValid: false, error: `Invalid protocol: ${url.protocol}` };
5939
+ }
5940
+ if (url.username || url.password) {
5941
+ return { isValid: false, error: "URLs with credentials not allowed" };
5942
+ }
5943
+ const hostname = url.hostname.toLowerCase();
5944
+ if (isBlockedHostname(hostname) || isIPAddress(hostname) && isBlockedIP(hostname)) {
5945
+ return { isValid: false, error: "Access to internal addresses not allowed" };
5946
+ }
5947
+ return { isValid: true, sanitizedUrl: url.toString() };
5948
+ } catch {
5949
+ return { isValid: false, error: "Invalid URL format" };
5950
+ }
5951
+ }
5952
+
5763
5953
  // src/tools/distribution.ts
5764
5954
  init_supabase();
5765
5955
  init_quality();
@@ -5799,16 +5989,42 @@ function asEnvelope2(data) {
5799
5989
  data
5800
5990
  };
5801
5991
  }
5992
+ function isAlreadyR2Signed(url) {
5993
+ try {
5994
+ const u = new URL(url);
5995
+ return u.searchParams.has("X-Amz-Signature");
5996
+ } catch {
5997
+ return false;
5998
+ }
5999
+ }
6000
+ async function rehostExternalUrl(mediaUrl, projectId) {
6001
+ if (isAlreadyR2Signed(mediaUrl)) {
6002
+ return { signedUrl: mediaUrl, r2Key: "" };
6003
+ }
6004
+ const ssrf = quickSSRFCheck(mediaUrl);
6005
+ if (!ssrf.isValid) {
6006
+ return { error: ssrf.error ?? "URL rejected by SSRF check" };
6007
+ }
6008
+ const { data, error } = await callEdgeFunction(
6009
+ "upload-to-r2",
6010
+ { url: ssrf.sanitizedUrl ?? mediaUrl, projectId },
6011
+ { timeoutMs: 6e4 }
6012
+ );
6013
+ if (error || !data?.key || !data?.url) {
6014
+ return { error: error ?? "upload-to-r2 returned no key" };
6015
+ }
6016
+ return { signedUrl: data.url, r2Key: data.key };
6017
+ }
5802
6018
  function registerDistributionTools(server2) {
5803
6019
  server2.tool(
5804
6020
  "schedule_post",
5805
- '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.',
5806
6022
  {
5807
6023
  media_url: z3.string().optional().describe(
5808
- "URL of the media file to post. Public URL or R2 signed URL. Not needed if media_urls, r2_key, or job_id is provided."
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."
5809
6025
  ),
5810
6026
  media_urls: z3.array(z3.string()).optional().describe(
5811
- "Array of 2-10 image URLs for carousel posts. Each must be publicly accessible or R2 signed URL. Use with media_type=CAROUSEL_ALBUM."
6027
+ "Array of 2-10 image URLs for carousel posts. Same rehosting rules as media_url \u2014 ephemeral URLs are persisted automatically. Use with media_type=CAROUSEL_ALBUM."
5812
6028
  ),
5813
6029
  r2_key: z3.string().optional().describe(
5814
6030
  "R2 object key from upload_media. Signed on demand at post time \u2014 survives scheduling delays. Alternative to media_url."
@@ -5897,7 +6113,10 @@ function registerDistributionTools(server2) {
5897
6113
  ),
5898
6114
  project_id: z3.string().optional().describe("Social Neuron project ID to associate this post with."),
5899
6115
  response_format: z3.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text."),
5900
- attribution: z3.boolean().optional().describe('If true, appends "Created with Social Neuron" to the caption. Default: false.')
6116
+ attribution: z3.boolean().optional().describe('If true, appends "Created with Social Neuron" to the caption. Default: false.'),
6117
+ auto_rehost: z3.boolean().optional().describe(
6118
+ "Whether to persist non-R2 media_url/media_urls into R2 before posting. Default: true. Set to false only if you know the source URL will outlive the scheduling window and every target platform supports URL ingest."
6119
+ )
5901
6120
  },
5902
6121
  async ({
5903
6122
  media_url,
@@ -5911,6 +6130,7 @@ function registerDistributionTools(server2) {
5911
6130
  project_id,
5912
6131
  response_format,
5913
6132
  attribution,
6133
+ auto_rehost,
5914
6134
  r2_key,
5915
6135
  r2_keys,
5916
6136
  job_id,
@@ -6022,12 +6242,54 @@ function registerDistributionTools(server2) {
6022
6242
  }
6023
6243
  resolvedMediaUrls = resolved;
6024
6244
  }
6245
+ const shouldRehost = auto_rehost !== false;
6246
+ if (shouldRehost && resolvedMediaUrl && !isAlreadyR2Signed(resolvedMediaUrl)) {
6247
+ const rehost = await rehostExternalUrl(resolvedMediaUrl, project_id);
6248
+ if ("error" in rehost) {
6249
+ return {
6250
+ content: [
6251
+ {
6252
+ type: "text",
6253
+ text: `Failed to persist media URL into R2: ${rehost.error}. Try upload_media first and pass r2_key instead, or set auto_rehost=false if you're sure the URL is publicly durable and every target platform accepts URL ingest.`
6254
+ }
6255
+ ],
6256
+ isError: true
6257
+ };
6258
+ }
6259
+ resolvedMediaUrl = rehost.signedUrl;
6260
+ }
6261
+ if (shouldRehost && resolvedMediaUrls && resolvedMediaUrls.length > 0) {
6262
+ const needsRehost = resolvedMediaUrls.map((u) => !isAlreadyR2Signed(u));
6263
+ if (needsRehost.some(Boolean)) {
6264
+ const rehosted = await Promise.all(
6265
+ resolvedMediaUrls.map(
6266
+ (u, i) => needsRehost[i] ? rehostExternalUrl(u, project_id) : Promise.resolve({ signedUrl: u, r2Key: "" })
6267
+ )
6268
+ );
6269
+ const failIdx = rehosted.findIndex((r) => "error" in r);
6270
+ if (failIdx !== -1) {
6271
+ const failed = rehosted[failIdx];
6272
+ return {
6273
+ content: [
6274
+ {
6275
+ type: "text",
6276
+ text: `Failed to persist media_urls[${failIdx}] into R2: ${failed.error}. Try upload_media first and pass r2_keys instead, or set auto_rehost=false.`
6277
+ }
6278
+ ],
6279
+ isError: true
6280
+ };
6281
+ }
6282
+ resolvedMediaUrls = rehosted.map(
6283
+ (r) => r.signedUrl
6284
+ );
6285
+ }
6286
+ }
6025
6287
  } catch (resolveErr) {
6026
6288
  return {
6027
6289
  content: [
6028
6290
  {
6029
6291
  type: "text",
6030
- text: `Failed to resolve media: ${sanitizeError2(resolveErr)}`
6292
+ text: `Failed to resolve media: ${sanitizeError(resolveErr)}`
6031
6293
  }
6032
6294
  ],
6033
6295
  isError: true
@@ -6392,7 +6654,7 @@ Created with Social Neuron`;
6392
6654
  return { content: [{ type: "text", text: lines.join("\n") }], isError: false };
6393
6655
  } catch (err) {
6394
6656
  const durationMs = Date.now() - startedAt;
6395
- const message = sanitizeError2(err);
6657
+ const message = sanitizeError(err);
6396
6658
  logMcpToolInvocation({
6397
6659
  toolName: "find_next_slots",
6398
6660
  status: "error",
@@ -6912,7 +7174,7 @@ Created with Social Neuron`;
6912
7174
  };
6913
7175
  } catch (err) {
6914
7176
  const durationMs = Date.now() - startedAt;
6915
- const message = sanitizeError2(err);
7177
+ const message = sanitizeError(err);
6916
7178
  logMcpToolInvocation({
6917
7179
  toolName: "schedule_content_plan",
6918
7180
  status: "error",
@@ -6935,6 +7197,19 @@ import { readFile } from "node:fs/promises";
6935
7197
  import { basename, extname } from "node:path";
6936
7198
  init_supabase();
6937
7199
  var MAX_BASE64_SIZE = 10 * 1024 * 1024;
7200
+ var ALLOWED_UPLOAD_TYPES = /* @__PURE__ */ new Set([
7201
+ "image/png",
7202
+ "image/jpeg",
7203
+ "image/gif",
7204
+ "image/webp",
7205
+ "image/svg+xml",
7206
+ "video/mp4",
7207
+ "video/quicktime",
7208
+ "video/x-msvideo",
7209
+ "video/webm"
7210
+ ]);
7211
+ var BASE64_CHARS = /^[A-Za-z0-9+/]+={0,2}$/;
7212
+ var DATA_URI_PREFIX = /^data:([^;,]+);base64,/;
6938
7213
  function maskR2Key(key) {
6939
7214
  const segments = key.split("/");
6940
7215
  return segments.length >= 3 ? `\u2026/${segments.slice(-2).join("/")}` : key;
@@ -6955,21 +7230,35 @@ function inferContentType(filePath) {
6955
7230
  };
6956
7231
  return map[ext] || "application/octet-stream";
6957
7232
  }
7233
+ function approxBase64Size(raw) {
7234
+ const len = raw.length;
7235
+ if (len === 0) return 0;
7236
+ let padding = 0;
7237
+ if (raw.endsWith("==")) padding = 2;
7238
+ else if (raw.endsWith("=")) padding = 1;
7239
+ return Math.floor(len * 3 / 4) - padding;
7240
+ }
6958
7241
  function registerMediaTools(server2) {
6959
7242
  server2.tool(
6960
7243
  "upload_media",
6961
- "Upload a local file or external URL to persistent R2 storage. Returns a durable r2_key that can be passed to schedule_post. Use for images, videos, or any media that needs to be posted to social platforms. Accepts local file paths (in MCP stdio mode) or public URLs. Max 10MB for base64 upload \u2014 larger files return an error with guidance.",
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).",
6962
7245
  {
6963
- source: z4.string().describe(
6964
- 'Local file path (e.g. "/Users/me/image.png") or public URL (e.g. "https://example.com/photo.jpg"). Local files are read and uploaded as base64. URLs are fetched by the server.'
7246
+ source: z4.string().optional().describe(
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.'
7248
+ ),
7249
+ file_data: z4.string().optional().describe(
7250
+ 'Base64-encoded file bytes, with or without a "data:<mime>;base64," prefix. Use this from remote agents (Claude Web/Desktop) that cannot provide a filesystem path. Max 10MB decoded.'
7251
+ ),
7252
+ file_name: z4.string().optional().describe(
7253
+ 'Optional filename for the upload (e.g. "hero.png"). Path components are stripped \u2014 only the basename is used.'
6965
7254
  ),
6966
7255
  content_type: z4.string().optional().describe(
6967
- 'MIME type (e.g. "image/png", "video/mp4"). Auto-detected from file extension if omitted.'
7256
+ 'MIME type (e.g. "image/png", "video/mp4"). Auto-detected from file extension for paths/URLs, or from the data: prefix on file_data. Required when passing raw base64 with no prefix.'
6968
7257
  ),
6969
7258
  project_id: z4.string().optional().describe("Project ID for R2 path organization."),
6970
7259
  response_format: z4.enum(["text", "json"]).optional().describe("Response format. Default: text.")
6971
7260
  },
6972
- async ({ source, content_type, project_id, response_format }) => {
7261
+ async ({ source, file_data, file_name, content_type, project_id, response_format }) => {
6973
7262
  const format = response_format ?? "text";
6974
7263
  const startedAt = Date.now();
6975
7264
  const userId = await getDefaultUserId();
@@ -6991,46 +7280,187 @@ function registerMediaTools(server2) {
6991
7280
  isError: true
6992
7281
  };
6993
7282
  }
6994
- const isUrl = source.startsWith("http://") || source.startsWith("https://");
6995
- const isLocalFile = !isUrl;
7283
+ if (!source && !file_data) {
7284
+ return {
7285
+ content: [
7286
+ {
7287
+ type: "text",
7288
+ text: "upload_media requires either `source` (path or URL) or `file_data` (base64)."
7289
+ }
7290
+ ],
7291
+ isError: true
7292
+ };
7293
+ }
7294
+ if (file_data) {
7295
+ let raw = file_data;
7296
+ let detectedType;
7297
+ const prefixMatch = raw.match(DATA_URI_PREFIX);
7298
+ if (prefixMatch) {
7299
+ detectedType = prefixMatch[1].trim().toLowerCase();
7300
+ raw = raw.slice(prefixMatch[0].length);
7301
+ }
7302
+ const ct = (content_type ?? detectedType ?? "").trim().toLowerCase();
7303
+ if (!ct) {
7304
+ return {
7305
+ content: [
7306
+ {
7307
+ type: "text",
7308
+ text: "content_type is required when file_data has no data: prefix."
7309
+ }
7310
+ ],
7311
+ isError: true
7312
+ };
7313
+ }
7314
+ if (!ALLOWED_UPLOAD_TYPES.has(ct)) {
7315
+ return {
7316
+ content: [
7317
+ {
7318
+ type: "text",
7319
+ text: `content_type "${ct}" is not supported. Allowed: ${[...ALLOWED_UPLOAD_TYPES].sort().join(", ")}.`
7320
+ }
7321
+ ],
7322
+ isError: true
7323
+ };
7324
+ }
7325
+ const stripped = raw.replace(/\s+/g, "");
7326
+ if (!BASE64_CHARS.test(stripped)) {
7327
+ return {
7328
+ content: [
7329
+ {
7330
+ type: "text",
7331
+ text: "file_data is not valid base64 \u2014 only A-Z, a-z, 0-9, +, /, = are allowed."
7332
+ }
7333
+ ],
7334
+ isError: true
7335
+ };
7336
+ }
7337
+ const approxSize = approxBase64Size(stripped);
7338
+ if (approxSize > MAX_BASE64_SIZE) {
7339
+ return {
7340
+ content: [
7341
+ {
7342
+ type: "text",
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.`
7344
+ }
7345
+ ],
7346
+ isError: true
7347
+ };
7348
+ }
7349
+ const safeName = basename(file_name ?? "upload");
7350
+ const uploadBody2 = {
7351
+ fileData: `data:${ct};base64,${stripped}`,
7352
+ contentType: ct,
7353
+ fileName: safeName,
7354
+ projectId: project_id
7355
+ };
7356
+ const { data: data2, error: error2 } = await callEdgeFunction("upload-to-r2", uploadBody2, { timeoutMs: 6e4 });
7357
+ if (error2) {
7358
+ await logMcpToolInvocation({
7359
+ toolName: "upload_media",
7360
+ status: "error",
7361
+ durationMs: Date.now() - startedAt,
7362
+ details: { error: error2, source: "base64", contentType: ct, size: approxSize }
7363
+ });
7364
+ return {
7365
+ content: [{ type: "text", text: `Upload failed: ${error2}` }],
7366
+ isError: true
7367
+ };
7368
+ }
7369
+ if (!data2?.key) {
7370
+ return {
7371
+ content: [{ type: "text", text: "Upload returned no R2 key." }],
7372
+ isError: true
7373
+ };
7374
+ }
7375
+ await logMcpToolInvocation({
7376
+ toolName: "upload_media",
7377
+ status: "success",
7378
+ durationMs: Date.now() - startedAt,
7379
+ details: {
7380
+ source: "base64",
7381
+ r2Key: data2.key,
7382
+ size: data2.size,
7383
+ contentType: data2.contentType
7384
+ }
7385
+ });
7386
+ if (format === "json") {
7387
+ return {
7388
+ content: [
7389
+ {
7390
+ type: "text",
7391
+ text: JSON.stringify(
7392
+ {
7393
+ r2_key: data2.key,
7394
+ signed_url: data2.url,
7395
+ size: data2.size,
7396
+ content_type: data2.contentType
7397
+ },
7398
+ null,
7399
+ 2
7400
+ )
7401
+ }
7402
+ ],
7403
+ isError: false
7404
+ };
7405
+ }
7406
+ return {
7407
+ content: [
7408
+ {
7409
+ type: "text",
7410
+ text: [
7411
+ "Media uploaded successfully.",
7412
+ `Media key: ${maskR2Key(data2.key)}`,
7413
+ `Signed URL: ${data2.url}`,
7414
+ `Size: ${(data2.size / 1024).toFixed(0)}KB`,
7415
+ `Type: ${data2.contentType}`,
7416
+ "",
7417
+ "Use job_id or response_format=json with schedule_post to post to any platform."
7418
+ ].join("\n")
7419
+ }
7420
+ ],
7421
+ isError: false
7422
+ };
7423
+ }
7424
+ const src = source;
7425
+ const isUrl = src.startsWith("http://") || src.startsWith("https://");
6996
7426
  let uploadBody;
6997
7427
  if (isUrl) {
6998
- const ct = content_type || inferContentType(source);
7428
+ const ct = content_type || inferContentType(src);
6999
7429
  uploadBody = {
7000
- url: source,
7430
+ url: src,
7001
7431
  contentType: ct,
7002
- fileName: basename(new URL(source).pathname) || "upload",
7432
+ fileName: basename(file_name ?? new URL(src).pathname) || "upload",
7003
7433
  projectId: project_id
7004
7434
  };
7005
7435
  } else {
7006
7436
  let fileBuffer;
7007
7437
  try {
7008
- fileBuffer = await readFile(source);
7009
- } catch (err) {
7438
+ fileBuffer = await readFile(src);
7439
+ } catch {
7010
7440
  await logMcpToolInvocation({
7011
7441
  toolName: "upload_media",
7012
7442
  status: "error",
7013
7443
  durationMs: Date.now() - startedAt,
7014
- details: { error: `File not found: ${source}` }
7444
+ details: { error: "File not found", source: "local" }
7015
7445
  });
7016
7446
  return {
7017
7447
  content: [
7018
7448
  {
7019
7449
  type: "text",
7020
- text: `File not found or not readable: ${source}`
7450
+ text: `File not found or not readable: ${src}. Remote agents (Claude Web/Desktop) cannot see the MCP server's filesystem \u2014 pass the bytes via the \`file_data\` parameter (base64, up to 10MB) instead.`
7021
7451
  }
7022
7452
  ],
7023
7453
  isError: true
7024
7454
  };
7025
7455
  }
7026
- const ct = content_type || inferContentType(source);
7456
+ const ct = content_type || inferContentType(src);
7027
7457
  if (fileBuffer.length > MAX_BASE64_SIZE) {
7028
7458
  const { data: putData, error: putError } = await callEdgeFunction(
7029
7459
  "get-signed-url",
7030
7460
  {
7031
7461
  operation: "put",
7032
7462
  contentType: ct,
7033
- filename: basename(source),
7463
+ filename: basename(file_name ?? src),
7034
7464
  projectId: project_id
7035
7465
  },
7036
7466
  { timeoutMs: 1e4 }
@@ -7132,7 +7562,7 @@ function registerMediaTools(server2) {
7132
7562
  uploadBody = {
7133
7563
  fileData: base64,
7134
7564
  contentType: ct,
7135
- fileName: basename(source),
7565
+ fileName: basename(file_name ?? src),
7136
7566
  projectId: project_id
7137
7567
  };
7138
7568
  }
@@ -7449,230 +7879,81 @@ function registerAnalyticsTools(server2) {
7449
7879
  `Analytics refresh triggered successfully.`,
7450
7880
  ` Posts processed: ${result.postsProcessed}`,
7451
7881
  ` Jobs queued: ${queued}`
7452
- ];
7453
- if (errored > 0) {
7454
- lines.push(` Errors: ${errored}`);
7455
- }
7456
- await logMcpToolInvocation({
7457
- toolName: "refresh_platform_analytics",
7458
- status: "success",
7459
- durationMs: Date.now() - startedAt,
7460
- details: {
7461
- postsProcessed: result.postsProcessed,
7462
- queued,
7463
- errored
7464
- }
7465
- });
7466
- if (format === "json") {
7467
- return {
7468
- content: [
7469
- {
7470
- type: "text",
7471
- text: JSON.stringify(
7472
- asEnvelope3({
7473
- success: true,
7474
- postsProcessed: result.postsProcessed,
7475
- queued,
7476
- errored
7477
- }),
7478
- null,
7479
- 2
7480
- )
7481
- }
7482
- ]
7483
- };
7484
- }
7485
- return { content: [{ type: "text", text: lines.join("\n") }] };
7486
- }
7487
- );
7488
- }
7489
- function formatAnalytics(summary, days, format) {
7490
- if (format === "json") {
7491
- return {
7492
- content: [
7493
- { type: "text", text: JSON.stringify(asEnvelope3({ ...summary, days }), null, 2) }
7494
- ]
7495
- };
7496
- }
7497
- const lines = [
7498
- `Analytics Summary (last ${days} days${summary.platform ? `, ${summary.platform}` : ""}):`,
7499
- "",
7500
- ` Total Views: ${summary.totalViews.toLocaleString()}`,
7501
- ` Total Engagement: ${summary.totalEngagement.toLocaleString()}`,
7502
- ` Posts Analyzed: ${summary.postCount}`,
7503
- ""
7504
- ];
7505
- if (summary.posts.length > 0) {
7506
- lines.push("Top Posts:");
7507
- const sorted = [...summary.posts].sort((a, b) => b.views - a.views);
7508
- for (const post of sorted.slice(0, 10)) {
7509
- const title = post.title || "(untitled)";
7510
- let line = ` [${post.platform}] ${title} - ${post.views.toLocaleString()} views, ${post.engagement} engagement`;
7511
- if (post.content_type || post.model_used) {
7512
- const meta = [post.content_type, post.model_used].filter(Boolean).join(", ");
7513
- line += ` (${meta})`;
7514
- }
7515
- lines.push(line);
7516
- }
7517
- }
7518
- return {
7519
- content: [{ type: "text", text: lines.join("\n") }]
7520
- };
7521
- }
7522
-
7523
- // src/tools/brand.ts
7524
- init_edge_function();
7525
- init_supabase();
7526
- import { z as z6 } from "zod";
7527
-
7528
- // src/lib/ssrf.ts
7529
- var BLOCKED_IP_PATTERNS = [
7530
- // IPv4 localhost/loopback
7531
- /^127\./,
7532
- /^0\./,
7533
- // IPv4 private ranges (RFC 1918)
7534
- /^10\./,
7535
- /^172\.(1[6-9]|2[0-9]|3[0-1])\./,
7536
- /^192\.168\./,
7537
- // IPv4 link-local
7538
- /^169\.254\./,
7539
- // Cloud metadata endpoint (AWS, GCP, Azure)
7540
- /^169\.254\.169\.254$/,
7541
- // IPv4 broadcast
7542
- /^255\./,
7543
- // Shared address space (RFC 6598)
7544
- /^100\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])\./
7545
- ];
7546
- var BLOCKED_IPV6_PATTERNS = [
7547
- /^::1$/i,
7548
- // loopback
7549
- /^::$/i,
7550
- // unspecified
7551
- /^fe[89ab][0-9a-f]:/i,
7552
- // link-local fe80::/10
7553
- /^fc[0-9a-f]:/i,
7554
- // unique local fc00::/7
7555
- /^fd[0-9a-f]:/i,
7556
- // unique local fc00::/7
7557
- /^::ffff:127\./i,
7558
- // IPv4-mapped localhost
7559
- /^::ffff:(0|10|127|169\.254|172\.(1[6-9]|2[0-9]|3[0-1])|192\.168)\./i
7560
- // IPv4-mapped private
7561
- ];
7562
- var BLOCKED_HOSTNAMES = [
7563
- "localhost",
7564
- "localhost.localdomain",
7565
- "local",
7566
- "127.0.0.1",
7567
- "0.0.0.0",
7568
- "[::1]",
7569
- "[::ffff:127.0.0.1]",
7570
- // Cloud metadata endpoints
7571
- "metadata.google.internal",
7572
- "metadata.goog",
7573
- "instance-data",
7574
- "instance-data.ec2.internal"
7575
- ];
7576
- var ALLOWED_PROTOCOLS = ["http:", "https:"];
7577
- var BLOCKED_PORTS = [22, 23, 25, 110, 143, 445, 3306, 5432, 6379, 27017, 11211];
7578
- function isBlockedIP(ip) {
7579
- const normalized = ip.replace(/^\[/, "").replace(/\]$/, "");
7580
- if (normalized.includes(":")) {
7581
- return BLOCKED_IPV6_PATTERNS.some((pattern) => pattern.test(normalized));
7582
- }
7583
- return BLOCKED_IP_PATTERNS.some((pattern) => pattern.test(normalized));
7584
- }
7585
- function isBlockedHostname(hostname) {
7586
- return BLOCKED_HOSTNAMES.includes(hostname.toLowerCase());
7587
- }
7588
- function isIPAddress(hostname) {
7589
- const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/;
7590
- const ipv6Pattern = /^\[?[a-fA-F0-9:]+\]?$/;
7591
- return ipv4Pattern.test(hostname) || ipv6Pattern.test(hostname);
7592
- }
7593
- async function validateUrlForSSRF(urlString) {
7594
- try {
7595
- const url = new URL(urlString);
7596
- if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {
7597
- return {
7598
- isValid: false,
7599
- error: `Invalid protocol: ${url.protocol}. Only HTTP and HTTPS are allowed.`
7600
- };
7601
- }
7602
- if (url.username || url.password) {
7603
- return {
7604
- isValid: false,
7605
- error: "URLs with embedded credentials are not allowed."
7606
- };
7607
- }
7608
- const hostname = url.hostname.toLowerCase();
7609
- if (isBlockedHostname(hostname)) {
7610
- return {
7611
- isValid: false,
7612
- error: "Access to internal/localhost addresses is not allowed."
7613
- };
7614
- }
7615
- if (isIPAddress(hostname) && isBlockedIP(hostname)) {
7616
- return {
7617
- isValid: false,
7618
- error: "Access to private/internal IP addresses is not allowed."
7619
- };
7620
- }
7621
- const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80;
7622
- if (BLOCKED_PORTS.includes(port)) {
7623
- return {
7624
- isValid: false,
7625
- error: `Access to port ${port} is not allowed.`
7626
- };
7627
- }
7628
- let resolvedIP;
7629
- if (!isIPAddress(hostname)) {
7630
- try {
7631
- const dns = await import("node:dns");
7632
- const resolver = new dns.promises.Resolver();
7633
- const resolvedIPs = [];
7634
- try {
7635
- const aRecords = await resolver.resolve4(hostname);
7636
- resolvedIPs.push(...aRecords);
7637
- } catch {
7638
- }
7639
- try {
7640
- const aaaaRecords = await resolver.resolve6(hostname);
7641
- resolvedIPs.push(...aaaaRecords);
7642
- } catch {
7643
- }
7644
- if (resolvedIPs.length === 0) {
7645
- return {
7646
- isValid: false,
7647
- error: "DNS resolution failed: hostname did not resolve to any address."
7648
- };
7649
- }
7650
- for (const ip of resolvedIPs) {
7651
- if (isBlockedIP(ip)) {
7652
- return {
7653
- isValid: false,
7654
- error: "Hostname resolves to a private/internal IP address."
7655
- };
7656
- }
7882
+ ];
7883
+ if (errored > 0) {
7884
+ lines.push(` Errors: ${errored}`);
7885
+ }
7886
+ await logMcpToolInvocation({
7887
+ toolName: "refresh_platform_analytics",
7888
+ status: "success",
7889
+ durationMs: Date.now() - startedAt,
7890
+ details: {
7891
+ postsProcessed: result.postsProcessed,
7892
+ queued,
7893
+ errored
7657
7894
  }
7658
- resolvedIP = resolvedIPs[0];
7659
- } catch {
7895
+ });
7896
+ if (format === "json") {
7660
7897
  return {
7661
- isValid: false,
7662
- error: "DNS resolution failed. Cannot verify hostname safety."
7898
+ content: [
7899
+ {
7900
+ type: "text",
7901
+ text: JSON.stringify(
7902
+ asEnvelope3({
7903
+ success: true,
7904
+ postsProcessed: result.postsProcessed,
7905
+ queued,
7906
+ errored
7907
+ }),
7908
+ null,
7909
+ 2
7910
+ )
7911
+ }
7912
+ ]
7663
7913
  };
7664
7914
  }
7915
+ return { content: [{ type: "text", text: lines.join("\n") }] };
7665
7916
  }
7666
- return { isValid: true, sanitizedUrl: url.toString(), resolvedIP };
7667
- } catch (error) {
7917
+ );
7918
+ }
7919
+ function formatAnalytics(summary, days, format) {
7920
+ if (format === "json") {
7668
7921
  return {
7669
- isValid: false,
7670
- error: `Invalid URL format: ${error instanceof Error ? error.message : "Unknown error"}`
7922
+ content: [
7923
+ { type: "text", text: JSON.stringify(asEnvelope3({ ...summary, days }), null, 2) }
7924
+ ]
7671
7925
  };
7672
7926
  }
7927
+ const lines = [
7928
+ `Analytics Summary (last ${days} days${summary.platform ? `, ${summary.platform}` : ""}):`,
7929
+ "",
7930
+ ` Total Views: ${summary.totalViews.toLocaleString()}`,
7931
+ ` Total Engagement: ${summary.totalEngagement.toLocaleString()}`,
7932
+ ` Posts Analyzed: ${summary.postCount}`,
7933
+ ""
7934
+ ];
7935
+ if (summary.posts.length > 0) {
7936
+ lines.push("Top Posts:");
7937
+ const sorted = [...summary.posts].sort((a, b) => b.views - a.views);
7938
+ for (const post of sorted.slice(0, 10)) {
7939
+ const title = post.title || "(untitled)";
7940
+ let line = ` [${post.platform}] ${title} - ${post.views.toLocaleString()} views, ${post.engagement} engagement`;
7941
+ if (post.content_type || post.model_used) {
7942
+ const meta = [post.content_type, post.model_used].filter(Boolean).join(", ");
7943
+ line += ` (${meta})`;
7944
+ }
7945
+ lines.push(line);
7946
+ }
7947
+ }
7948
+ return {
7949
+ content: [{ type: "text", text: lines.join("\n") }]
7950
+ };
7673
7951
  }
7674
7952
 
7675
7953
  // src/tools/brand.ts
7954
+ init_edge_function();
7955
+ init_supabase();
7956
+ import { z as z6 } from "zod";
7676
7957
  init_version();
7677
7958
  function asEnvelope4(data) {
7678
7959
  return {
@@ -7822,7 +8103,7 @@ function registerBrandTools(server2) {
7822
8103
  );
7823
8104
  server2.tool(
7824
8105
  "save_brand_profile",
7825
- "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.",
7826
8107
  {
7827
8108
  project_id: z6.string().uuid().optional().describe("Project ID. Defaults to active project context."),
7828
8109
  brand_context: z6.record(z6.string(), z6.unknown()).describe("Brand context payload to save to brand_profiles.brand_context."),
@@ -8196,7 +8477,7 @@ function registerScreenshotTools(server2) {
8196
8477
  };
8197
8478
  } catch (err) {
8198
8479
  await closeBrowser();
8199
- const message = sanitizeError2(err);
8480
+ const message = sanitizeError(err);
8200
8481
  await logMcpToolInvocation({
8201
8482
  toolName: "capture_app_page",
8202
8483
  status: "error",
@@ -8352,7 +8633,7 @@ function registerScreenshotTools(server2) {
8352
8633
  };
8353
8634
  } catch (err) {
8354
8635
  await closeBrowser();
8355
- const message = sanitizeError2(err);
8636
+ const message = sanitizeError(err);
8356
8637
  await logMcpToolInvocation({
8357
8638
  toolName: "capture_screenshot",
8358
8639
  status: "error",
@@ -8646,7 +8927,7 @@ function registerRemotionTools(server2) {
8646
8927
  ]
8647
8928
  };
8648
8929
  } catch (err) {
8649
- const message = sanitizeError2(err);
8930
+ const message = sanitizeError(err);
8650
8931
  await logMcpToolInvocation({
8651
8932
  toolName: "render_demo_video",
8652
8933
  status: "error",
@@ -8774,7 +9055,7 @@ function registerRemotionTools(server2) {
8774
9055
  ]
8775
9056
  };
8776
9057
  } catch (err) {
8777
- const message = sanitizeError2(err);
9058
+ const message = sanitizeError(err);
8778
9059
  await logMcpToolInvocation({
8779
9060
  toolName: "render_template_video",
8780
9061
  status: "error",
@@ -9407,7 +9688,7 @@ function registerCommentsTools(server2) {
9407
9688
  );
9408
9689
  server2.tool(
9409
9690
  "post_comment",
9410
- "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.",
9411
9692
  {
9412
9693
  video_id: z11.string().describe("The YouTube video ID to comment on."),
9413
9694
  text: z11.string().min(1).describe("The comment text."),
@@ -9478,7 +9759,7 @@ function registerCommentsTools(server2) {
9478
9759
  );
9479
9760
  server2.tool(
9480
9761
  "moderate_comment",
9481
- "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.',
9482
9763
  {
9483
9764
  comment_id: z11.string().describe("The comment ID to moderate."),
9484
9765
  moderation_status: z11.enum(["published", "rejected"]).describe('"published" to approve, "rejected" to hide.'),
@@ -10711,7 +10992,7 @@ function registerExtractionTools(server2) {
10711
10992
  };
10712
10993
  } catch (err) {
10713
10994
  const durationMs = Date.now() - startedAt;
10714
- const message = sanitizeError2(err);
10995
+ const message = sanitizeError(err);
10715
10996
  logMcpToolInvocation({
10716
10997
  toolName: "extract_url_content",
10717
10998
  status: "error",
@@ -11271,7 +11552,7 @@ ${rawText.slice(0, 1e3)}`
11271
11552
  }
11272
11553
  } catch (persistErr) {
11273
11554
  const durationMs2 = Date.now() - startedAt;
11274
- const message = sanitizeError2(persistErr);
11555
+ const message = sanitizeError(persistErr);
11275
11556
  logMcpToolInvocation({
11276
11557
  toolName: "plan_content_week",
11277
11558
  status: "error",
@@ -11303,7 +11584,7 @@ ${rawText.slice(0, 1e3)}`
11303
11584
  };
11304
11585
  } catch (err) {
11305
11586
  const durationMs = Date.now() - startedAt;
11306
- const message = sanitizeError2(err);
11587
+ const message = sanitizeError(err);
11307
11588
  logMcpToolInvocation({
11308
11589
  toolName: "plan_content_week",
11309
11590
  status: "error",
@@ -11395,7 +11676,7 @@ ${rawText.slice(0, 1e3)}`
11395
11676
  };
11396
11677
  } catch (err) {
11397
11678
  const durationMs = Date.now() - startedAt;
11398
- const message = sanitizeError2(err);
11679
+ const message = sanitizeError(err);
11399
11680
  logMcpToolInvocation({
11400
11681
  toolName: "save_content_plan",
11401
11682
  status: "error",
@@ -11411,7 +11692,7 @@ ${rawText.slice(0, 1e3)}`
11411
11692
  );
11412
11693
  server2.tool(
11413
11694
  "get_content_plan",
11414
- "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.",
11415
11696
  {
11416
11697
  plan_id: z20.string().uuid().describe("Persisted content plan ID"),
11417
11698
  response_format: z20.enum(["text", "json"]).default("json")
@@ -11465,7 +11746,7 @@ ${rawText.slice(0, 1e3)}`
11465
11746
  );
11466
11747
  server2.tool(
11467
11748
  "update_content_plan",
11468
- "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.",
11469
11750
  {
11470
11751
  plan_id: z20.string().uuid(),
11471
11752
  post_updates: z20.array(
@@ -11608,7 +11889,7 @@ function asEnvelope17(data) {
11608
11889
  function registerPlanApprovalTools(server2) {
11609
11890
  server2.tool(
11610
11891
  "create_plan_approvals",
11611
- "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.',
11612
11893
  {
11613
11894
  plan_id: z21.string().uuid().describe("Content plan ID"),
11614
11895
  posts: z21.array(
@@ -11688,7 +11969,7 @@ function registerPlanApprovalTools(server2) {
11688
11969
  );
11689
11970
  server2.tool(
11690
11971
  "list_plan_approvals",
11691
- "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.",
11692
11973
  {
11693
11974
  plan_id: z21.string().uuid().describe("Content plan ID"),
11694
11975
  status: z21.enum(["pending", "approved", "rejected", "edited"]).optional(),
@@ -11748,7 +12029,7 @@ function registerPlanApprovalTools(server2) {
11748
12029
  );
11749
12030
  server2.tool(
11750
12031
  "respond_plan_approval",
11751
- "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.',
11752
12033
  {
11753
12034
  approval_id: z21.string().uuid().describe("Approval item ID"),
11754
12035
  decision: z21.enum(["approved", "rejected", "edited"]),
@@ -12007,7 +12288,7 @@ function registerPipelineTools(server2) {
12007
12288
  return { content: [{ type: "text", text: lines.join("\n") }] };
12008
12289
  } catch (err) {
12009
12290
  const durationMs = Date.now() - startedAt;
12010
- const message = sanitizeError2(err);
12291
+ const message = sanitizeError(err);
12011
12292
  logMcpToolInvocation({
12012
12293
  toolName: "check_pipeline_readiness",
12013
12294
  status: "error",
@@ -12168,7 +12449,7 @@ function registerPipelineTools(server2) {
12168
12449
  } catch (deductErr) {
12169
12450
  errors.push({
12170
12451
  stage: "planning",
12171
- message: `Credit deduction failed: ${sanitizeError2(deductErr)}`
12452
+ message: `Credit deduction failed: ${sanitizeError(deductErr)}`
12172
12453
  });
12173
12454
  }
12174
12455
  }
@@ -12339,7 +12620,7 @@ function registerPipelineTools(server2) {
12339
12620
  } catch (schedErr) {
12340
12621
  errors.push({
12341
12622
  stage: "schedule",
12342
- message: `Failed to schedule ${post.id}: ${sanitizeError2(schedErr)}`
12623
+ message: `Failed to schedule ${post.id}: ${sanitizeError(schedErr)}`
12343
12624
  });
12344
12625
  }
12345
12626
  }
@@ -12428,7 +12709,7 @@ function registerPipelineTools(server2) {
12428
12709
  return { content: [{ type: "text", text: lines.join("\n") }] };
12429
12710
  } catch (err) {
12430
12711
  const durationMs = Date.now() - startedAt;
12431
- const message = sanitizeError2(err);
12712
+ const message = sanitizeError(err);
12432
12713
  logMcpToolInvocation({
12433
12714
  toolName: "run_content_pipeline",
12434
12715
  status: "error",
@@ -12652,7 +12933,7 @@ function registerPipelineTools(server2) {
12652
12933
  return { content: [{ type: "text", text: lines.join("\n") }] };
12653
12934
  } catch (err) {
12654
12935
  const durationMs = Date.now() - startedAt;
12655
- const message = sanitizeError2(err);
12936
+ const message = sanitizeError(err);
12656
12937
  logMcpToolInvocation({
12657
12938
  toolName: "auto_approve_plan",
12658
12939
  status: "error",
@@ -12832,7 +13113,7 @@ ${i + 1}. ${s.topic}`);
12832
13113
  return { content: [{ type: "text", text: lines.join("\n") }] };
12833
13114
  } catch (err) {
12834
13115
  const durationMs = Date.now() - startedAt;
12835
- const message = sanitizeError2(err);
13116
+ const message = sanitizeError(err);
12836
13117
  logMcpToolInvocation({
12837
13118
  toolName: "suggest_next_content",
12838
13119
  status: "error",
@@ -13173,7 +13454,7 @@ Best: ${best.id.slice(0, 8)}... (${best.platform}) \u2014 ${best.views.toLocaleS
13173
13454
  return { content: [{ type: "text", text: lines.join("\n") }] };
13174
13455
  } catch (err) {
13175
13456
  const durationMs = Date.now() - startedAt;
13176
- const message = sanitizeError2(err);
13457
+ const message = sanitizeError(err);
13177
13458
  logMcpToolInvocation({
13178
13459
  toolName: "generate_performance_digest",
13179
13460
  status: "error",
@@ -13270,7 +13551,7 @@ Best: ${best.id.slice(0, 8)}... (${best.platform}) \u2014 ${best.views.toLocaleS
13270
13551
  return { content: [{ type: "text", text: lines.join("\n") }] };
13271
13552
  } catch (err) {
13272
13553
  const durationMs = Date.now() - startedAt;
13273
- const message = sanitizeError2(err);
13554
+ const message = sanitizeError(err);
13274
13555
  logMcpToolInvocation({
13275
13556
  toolName: "detect_anomalies",
13276
13557
  status: "error",
@@ -13577,11 +13858,11 @@ function hexToLab(hex) {
13577
13858
  const lb = srgbToLinear(b);
13578
13859
  const x = lr * 0.4124564 + lg * 0.3575761 + lb * 0.1804375;
13579
13860
  const y = lr * 0.2126729 + lg * 0.7151522 + lb * 0.072175;
13580
- const z29 = lr * 0.0193339 + lg * 0.119192 + lb * 0.9503041;
13861
+ const z31 = lr * 0.0193339 + lg * 0.119192 + lb * 0.9503041;
13581
13862
  const f = (t) => t > 8856e-6 ? Math.cbrt(t) : 7.787 * t + 16 / 116;
13582
13863
  const fx = f(x / 0.95047);
13583
13864
  const fy = f(y / 1);
13584
- const fz = f(z29 / 1.08883);
13865
+ const fz = f(z31 / 1.08883);
13585
13866
  return { L: 116 * fy - 16, a: 500 * (fx - fy), b: 200 * (fy - fz) };
13586
13867
  }
13587
13868
  function deltaE2000(lab1, lab2) {
@@ -14303,7 +14584,7 @@ function registerCarouselTools(server2) {
14303
14584
  slideNumber: slide.slideNumber,
14304
14585
  jobId: null,
14305
14586
  model: image_model,
14306
- error: sanitizeError2(err)
14587
+ error: sanitizeError(err)
14307
14588
  };
14308
14589
  }
14309
14590
  })
@@ -14408,6 +14689,345 @@ function registerCarouselTools(server2) {
14408
14689
  );
14409
14690
  }
14410
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
+
14411
15031
  // src/lib/register-tools.ts
14412
15032
  function applyScopeEnforcement(server2, scopeResolver) {
14413
15033
  const originalTool = server2.tool.bind(server2);
@@ -14437,7 +15057,7 @@ function applyScopeEnforcement(server2, scopeResolver) {
14437
15057
  content: [
14438
15058
  {
14439
15059
  type: "text",
14440
- 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.`
14441
15061
  }
14442
15062
  ],
14443
15063
  isError: true
@@ -14513,21 +15133,25 @@ function registerAllTools(server2, options) {
14513
15133
  registerDigestTools(server2);
14514
15134
  registerBrandRuntimeTools(server2);
14515
15135
  registerCarouselTools(server2);
15136
+ registerConnectionTools(server2);
15137
+ if (!options?.skipApps) {
15138
+ registerContentCalendarApp(server2);
15139
+ }
14516
15140
  applyAnnotations(server2);
14517
15141
  }
14518
15142
 
14519
15143
  // src/prompts.ts
14520
- import { z as z28 } from "zod";
15144
+ import { z as z30 } from "zod";
14521
15145
  function registerPrompts(server2) {
14522
15146
  server2.prompt(
14523
15147
  "create_weekly_content_plan",
14524
15148
  "Generate a full week of social media content (7 days, multiple platforms). Returns a structured plan with topics, formats, and posting times.",
14525
15149
  {
14526
- niche: z28.string().describe('Your content niche or industry (e.g., "fitness coaching", "SaaS marketing")'),
14527
- 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(
14528
15152
  'Comma-separated platforms to target (default: "YouTube, Instagram, TikTok, LinkedIn")'
14529
15153
  ),
14530
- 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")')
14531
15155
  },
14532
15156
  ({ niche, platforms, tone }) => {
14533
15157
  const targetPlatforms = platforms || "YouTube, Instagram, TikTok, LinkedIn";
@@ -14569,8 +15193,8 @@ After building the plan, use \`save_content_plan\` to save it.`
14569
15193
  "analyze_top_content",
14570
15194
  "Analyze your best-performing posts to identify patterns and replicate success. Returns insights on hooks, formats, timing, and topics that resonate.",
14571
15195
  {
14572
- timeframe: z28.string().optional().describe('Analysis period (default: "30 days"). E.g., "7 days", "90 days"'),
14573
- 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")')
14574
15198
  },
14575
15199
  ({ timeframe, platform: platform3 }) => {
14576
15200
  const period = timeframe || "30 days";
@@ -14607,10 +15231,10 @@ Format as a clear, actionable performance report.`
14607
15231
  "repurpose_content",
14608
15232
  "Take one piece of content and transform it into 8-10 pieces across multiple platforms and formats.",
14609
15233
  {
14610
- source: z28.string().describe(
15234
+ source: z30.string().describe(
14611
15235
  "The source content to repurpose \u2014 a URL, transcript, or the content text itself"
14612
15236
  ),
14613
- target_platforms: z28.string().optional().describe(
15237
+ target_platforms: z30.string().optional().describe(
14614
15238
  'Comma-separated target platforms (default: "Twitter, LinkedIn, Instagram, TikTok, YouTube")'
14615
15239
  )
14616
15240
  },
@@ -14652,9 +15276,9 @@ For each piece, include the platform, format, character count, and suggested pos
14652
15276
  "setup_brand_voice",
14653
15277
  "Define or refine your brand voice profile so all generated content stays on-brand. Walks through tone, audience, values, and style.",
14654
15278
  {
14655
- brand_name: z28.string().describe("Your brand or business name"),
14656
- industry: z28.string().optional().describe('Your industry or niche (e.g., "B2B SaaS", "fitness coaching")'),
14657
- 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")
14658
15282
  },
14659
15283
  ({ brand_name, industry, website }) => {
14660
15284
  const industryContext = industry ? ` in the ${industry} space` : "";
@@ -15168,7 +15792,7 @@ var server = new McpServer({
15168
15792
  version: MCP_VERSION
15169
15793
  });
15170
15794
  applyScopeEnforcement(server, getAuthenticatedScopes);
15171
- registerAllTools(server);
15795
+ registerAllTools(server, { skipApps: true });
15172
15796
  registerPrompts(server);
15173
15797
  registerResources(server);
15174
15798
  async function shutdown() {