@cmo-so/mcp 0.3.0 → 0.5.0

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.
Files changed (2) hide show
  1. package/dist/cmo-mcp.mjs +361 -5
  2. package/package.json +1 -1
package/dist/cmo-mcp.mjs CHANGED
@@ -7199,7 +7199,7 @@ var require_dist = __commonJS({
7199
7199
  // package.json
7200
7200
  var package_default = {
7201
7201
  name: "@cmo-so/cli",
7202
- version: "0.3.0",
7202
+ version: "0.5.0",
7203
7203
  description: "CMO.so on the command line: your websites, ideas, blog posts, LinkedIn queue and results, plus a local MCP server for Claude Desktop, Cursor and other MCP clients.",
7204
7204
  type: "module",
7205
7205
  license: "MIT",
@@ -7530,7 +7530,16 @@ function dataUrl(type, bytes) {
7530
7530
  }
7531
7531
 
7532
7532
  // ../../packages/api-client/src/domains.ts
7533
- var MEMORY_CHANNELS = ["general", "blog", "linkedin", "facebook", "instagram", "google_business"];
7533
+ var MEMORY_CHANNELS = [
7534
+ "general",
7535
+ "blog",
7536
+ "linkedin",
7537
+ "facebook",
7538
+ "instagram",
7539
+ "google_business",
7540
+ "youtube",
7541
+ "tiktok"
7542
+ ];
7534
7543
  function addWebsite(api2, url) {
7535
7544
  return api2.request("/v1/domains", {
7536
7545
  method: "POST",
@@ -7763,7 +7772,38 @@ function domainApi(api2, domain) {
7763
7772
  `${base}/social/google_business/location`,
7764
7773
  { connection_id: connectionId }
7765
7774
  ),
7775
+ /** How the Google Business Profile is doing: the last 28 days and the 28 before (Google lags 2-3 days). */
7776
+ googleBusinessPerformance: (refresh = false) => api2.request(`${base}/social/google_business/performance${refresh ? "?refresh=true" : ""}`),
7766
7777
  disconnectGoogleBusiness: (connectionId) => api2.request(`${base}/social/google_business/${connectionId}`, { method: "DELETE" }),
7778
+ /** A Google sign-in link for this website's YouTube channel (docs/prd/youtube.md §7); back with ?youtube=connected
7779
+ * (or none when the Google account has no channel, error&message=…). */
7780
+ connectYouTube: (returnTo = "close") => post(`${base}/social/youtube/connect`, { return_to: returnTo }),
7781
+ /** Your YouTube channels here (name, @handle, picture, subscribers), and the one this website posts to. */
7782
+ youtubeChannels: () => api2.request(
7783
+ `${base}/social/youtube/channels`
7784
+ ),
7785
+ chooseYouTubeChannel: (connectionId) => put(
7786
+ `${base}/social/youtube/channel`,
7787
+ {
7788
+ connection_id: connectionId
7789
+ }
7790
+ ),
7791
+ /** Disconnect: the token is deleted and Google's access revoked (YouTube's policy). */
7792
+ disconnectYouTube: (connectionId) => api2.request(`${base}/social/youtube/${connectionId}`, { method: "DELETE" }),
7793
+ /** A TikTok sign-in link for this website (docs/prd/tiktok.md §7); back with ?tiktok=connected (error&message=…). */
7794
+ connectTikTok: (returnTo = "close") => post(`${base}/social/tiktok/connect`, { return_to: returnTo }),
7795
+ /** What the TikTok account allows right now: show it in the TikTok post editor (TikTok's guidelines). */
7796
+ tiktokCreator: () => api2.request(`${base}/social/tiktok/creator`),
7797
+ /** Disconnect: TikTok's access is revoked and the token deleted. */
7798
+ disconnectTikTok: (connectionId) => api2.request(`${base}/social/tiktok/${connectionId}`, { method: "DELETE" }),
7799
+ /** A TikTok post's settings: who can view it, comments / Duet / Stitch, content disclosure, AI label. */
7800
+ setSocialTikTok: (id, answers) => api2.request(`${base}/social/${id}`, {
7801
+ method: "PATCH",
7802
+ headers: { "Content-Type": "application/json" },
7803
+ body: JSON.stringify({ tiktok: answers })
7804
+ }),
7805
+ /** Ask to join the beta of these channels: an email says what we need (reply or WhatsApp); the team is told. */
7806
+ requestBeta: (req) => post(`${base}/social/beta-request`, req),
7767
7807
  /** One channel's own pause switch (pauseSocial / resumeSocial hold every channel). */
7768
7808
  setChannelPaused: (channel, paused) => post(`${base}/social/channels/${channel}/${paused ? "pause" : "resume"}`),
7769
7809
  /** How often one channel posts. */
@@ -7928,7 +7968,35 @@ function domainApi(api2, domain) {
7928
7968
  headers: { "Content-Type": "application/json" },
7929
7969
  body: JSON.stringify({ cta_type: ctaType })
7930
7970
  }),
7971
+ /** Make a Google Business post an Event or Offer (or back to an update with { type: "STANDARD" }). */
7972
+ setSocialGooglePost: (id, googlePost) => api2.request(`${base}/social/${id}`, {
7973
+ method: "PATCH",
7974
+ headers: { "Content-Type": "application/json" },
7975
+ body: JSON.stringify({ google_post: googlePost })
7976
+ }),
7931
7977
  resumeSocial: () => post(`${base}/social/resume`),
7978
+ /** Start a video upload: PUT each part_size slice of the file to its url, keep the ETags, then completeVideoUpload. */
7979
+ startVideoUpload: (file) => post(
7980
+ `${base}/social/video/upload`,
7981
+ file
7982
+ ),
7983
+ completeVideoUpload: (videoId, parts, edit) => post(`${base}/social/video/${videoId}/complete`, { parts, edit }),
7984
+ /** A direct link to a video file (not a YouTube / TikTok page). */
7985
+ videoFromLink: (url, edit) => post(`${base}/social/video/link`, { url, edit }),
7986
+ video: (videoId) => api2.request(`${base}/social/video/${videoId}`),
7987
+ /** Another part or framing; prepared again in the background. */
7988
+ editVideo: (videoId, edit) => post(`${base}/social/video/${videoId}/edit`, { edit }),
7989
+ /** A post on one video channel (YouTube by default, or TikTok) from a ready video, waiting in Review; Maggie
7990
+ * writes the details (YouTube) or the caption (TikTok) unless write is false. */
7991
+ postFromVideo: (videoId, opts = {}) => post(`${base}/social/video/${videoId}/post`, opts),
7992
+ youtubeSettings: () => api2.request(`${base}/social/youtube/settings`),
7993
+ setYouTubeSettings: (s) => put(`${base}/social/youtube/settings`, s),
7994
+ /** A YouTube post's title, tags, visibility, made-for-kids or altered-content answer. */
7995
+ setSocialYouTube: (id, answers) => api2.request(`${base}/social/${id}`, {
7996
+ method: "PATCH",
7997
+ headers: { "Content-Type": "application/json" },
7998
+ body: JSON.stringify({ youtube: answers })
7999
+ }),
7932
8000
  pauseAutopilot: () => post(`${base}/autopilot/pause`),
7933
8001
  resumeAutopilot: () => post(`${base}/autopilot/resume`),
7934
8002
  // Reports (docs/18 §6): report:read with the user's own key.
@@ -8168,6 +8236,9 @@ var list = (xs, n = 5) => {
8168
8236
  };
8169
8237
  var ids = (a) => (Array.isArray(a.ids) && a.ids.length ? a.ids : a.id != null ? [a.id] : []).map(Number);
8170
8238
  var keys = (a) => Object.keys(a ?? {}).filter((k) => a[k] !== void 0);
8239
+ var videoChannels = (a) => (Array.isArray(a.channels) && a.channels.length ? a.channels : ["youtube"]).map(
8240
+ (c) => c === "tiktok" ? "TikTok" : "YouTube"
8241
+ );
8171
8242
  var confirmCopy = {
8172
8243
  // --------------------------------------------------------- account / setup
8173
8244
  website_add: {
@@ -8349,6 +8420,36 @@ var confirmCopy = {
8349
8420
  en: (a) => `Bring back LinkedIn post #${a.id}.`,
8350
8421
  "zh-Hant": (a) => `\u6062\u5FA9 LinkedIn \u8CBC\u6587 #${a.id}\u3002`
8351
8422
  },
8423
+ youtube_connect_link: {
8424
+ en: () => "Make a link to connect your YouTube channel.",
8425
+ "zh-Hant": () => "\u7522\u751F\u9023\u63A5\u4F60 YouTube \u983B\u9053\u7684\u9023\u7D50\u3002"
8426
+ },
8427
+ tiktok_connect_link: {
8428
+ en: () => "Make a link to connect your TikTok account.",
8429
+ "zh-Hant": () => "\u7522\u751F\u9023\u63A5\u4F60 TikTok \u5E33\u865F\u7684\u9023\u7D50\u3002"
8430
+ },
8431
+ social_video_add: {
8432
+ en: (a) => {
8433
+ const names = videoChannels(a).join(" and ");
8434
+ return `Make a ${names} post from ${a.url}${a.write === false ? "" : ` (Maggie writes it: one AI generation per channel)`}; it waits for your OK.`;
8435
+ },
8436
+ "zh-Hant": (a) => {
8437
+ const names = videoChannels(a).join(" \u548C ");
8438
+ return `\u7528 ${a.url} \u505A ${names} \u5F71\u7247\u8CBC\u6587${a.write === false ? "" : "\uFF08Maggie \u4F86\u5BEB\uFF1A\u6BCF\u500B\u6E20\u9053\u7528\u4E00\u6B21 AI \u751F\u6210\uFF09"}\uFF0C\u7B49\u4F60\u78BA\u8A8D\u5F8C\u624D\u767C\u3002`;
8439
+ }
8440
+ },
8441
+ social_tiktok_set: {
8442
+ en: (a) => `Set TikTok post #${a.id}: ${keys(a).filter((k) => k !== "id").map((k) => `${k} ${String(a[k])}`).join(", ") || "its settings"}.`,
8443
+ "zh-Hant": (a) => `\u8A2D\u5B9A TikTok \u8CBC\u6587 #${a.id}\uFF1A${keys(a).filter((k) => k !== "id").map((k) => `${k} ${String(a[k])}`).join("\u3001") || "\u8A2D\u5B9A"}\u3002`
8444
+ },
8445
+ social_youtube_set: {
8446
+ en: (a) => `Change YouTube post #${a.id}: ${keys(a).filter((k) => k !== "id").join(", ") || "its details"}.`,
8447
+ "zh-Hant": (a) => `\u4FEE\u6539 YouTube \u8CBC\u6587 #${a.id}\uFF1A${keys(a).filter((k) => k !== "id").join("\u3001") || "\u7D30\u7BC0"}\u3002`
8448
+ },
8449
+ youtube_settings_set: {
8450
+ en: (a) => `Change your YouTube defaults: ${keys(a).join(", ") || "nothing"}.`,
8451
+ "zh-Hant": (a) => `\u4FEE\u6539 YouTube \u9810\u8A2D\uFF1A${keys(a).join("\u3001") || "\u7121"}\u3002`
8452
+ },
8352
8453
  social_image_new: {
8353
8454
  en: (a) => `Make a new picture for LinkedIn post #${a.id}${a.instruction ? `: ${String(a.instruction).slice(0, 300)}` : ""} (1 AI generation, in the background)${a.replace ? ", stopping the one being made for another post" : ""}.`,
8354
8455
  "zh-Hant": (a) => `\u70BA LinkedIn \u8CBC\u6587 #${a.id} \u751F\u6210\u65B0\u5716\u7247${a.instruction ? `\uFF1A${String(a.instruction).slice(0, 300)}` : ""}\uFF08\u7528 1 \u6B21 AI \u751F\u6210\uFF0C\u5728\u80CC\u666F\u9032\u884C\uFF09${a.replace ? "\uFF0C\u4E26\u505C\u6B62\u6B63\u5728\u70BA\u53E6\u4E00\u7BC7\u751F\u6210\u7684\u5716\u7247" : ""}\u3002`
@@ -10362,10 +10463,13 @@ var read5 = { resource: "autopilot", level: "read" };
10362
10463
  var write5 = { resource: "autopilot", level: "write" };
10363
10464
  var moves2 = ["up", "down", "top"];
10364
10465
  var CHANNEL_KEYS = ["linkedin", "facebook", "instagram", "google_business"];
10466
+ var ALL_CHANNEL_KEYS = [...CHANNEL_KEYS, "youtube", "tiktok"];
10467
+ var VIDEO_KEYS = ["youtube", "tiktok"];
10468
+ var TIKTOK_PRIVACY = ["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "FOLLOWER_OF_CREATOR", "SELF_ONLY"];
10365
10469
  var channelsParam = {
10366
10470
  type: "array",
10367
10471
  items: { type: "string", enum: CHANNEL_KEYS },
10368
- description: "Which channels to write for: linkedin, facebook. Leave out to write for every connected channel (one post each, one credit each); give one only when the user names it."
10472
+ description: "Which channels to write for: linkedin, facebook, instagram, google_business. Leave out to write for every connected channel (one post each, one credit each); give one only when the user names it. YouTube and TikTok take a video: use social_video_add."
10369
10473
  };
10370
10474
  var channelsOf = (value) => {
10371
10475
  const list2 = Array.isArray(value) ? value.map((v) => String(v).toLowerCase()).filter((v) => CHANNEL_KEYS.includes(v)) : [];
@@ -10388,7 +10492,15 @@ var item = (i) => ({
10388
10492
  campaign: i.campaign ?? null,
10389
10493
  failure: i.failure_message ?? null,
10390
10494
  link: i.provider_permalink ?? null,
10391
- text: clip(i.social_copy, 300)
10495
+ text: clip(i.social_copy, 300),
10496
+ ...i.provider === "youtube" ? {
10497
+ youtube: i.youtube ?? null,
10498
+ video: i.video ? { status: i.video.status, seconds: i.video.duration, shape: i.video.shape, error: i.video.error } : null
10499
+ } : {},
10500
+ ...i.provider === "tiktok" ? {
10501
+ tiktok: i.tiktok ?? null,
10502
+ video: i.video ? { status: i.video.status, seconds: i.video.duration, shape: i.video.shape, error: i.video.error } : null
10503
+ } : {}
10392
10504
  });
10393
10505
  var socialTools = [
10394
10506
  tool({
@@ -10797,7 +10909,7 @@ var socialTools = [
10797
10909
  description: "Pause or resume the user's social posting: one channel (linkedin or facebook), or every channel when no channel is given.",
10798
10910
  parameters: {
10799
10911
  type: "object",
10800
- properties: { paused: { type: "boolean" }, channel: { type: "string", enum: CHANNEL_KEYS } },
10912
+ properties: { paused: { type: "boolean" }, channel: { type: "string", enum: ALL_CHANNEL_KEYS } },
10801
10913
  required: ["paused"]
10802
10914
  },
10803
10915
  group: "social",
@@ -11353,6 +11465,250 @@ var socialTools = [
11353
11465
  return { id: out.id, button: out.cta_type ?? "LEARN_MORE", link: out.cta_url ?? null };
11354
11466
  }
11355
11467
  }),
11468
+ tool({
11469
+ name: "youtube_connect_link",
11470
+ description: "A link for the user to connect their YouTube channel (or reconnect): they sign in with the Google account that owns the channel and allow Maggie to upload videos and see the channel. Give them the link to open; never ask for a password. YouTube posts are short videos (5-30 seconds now) that the user approves one by one.",
11471
+ parameters: none,
11472
+ group: "social",
11473
+ need: write5,
11474
+ risk: "W1",
11475
+ gates: ["VER"],
11476
+ run: async (ctx) => {
11477
+ const out = await ws(ctx).connectYouTube("close");
11478
+ return { url: out.url, expires_in_seconds: out.expires_in_seconds };
11479
+ }
11480
+ }),
11481
+ tool({
11482
+ name: "tiktok_connect_link",
11483
+ description: "A link for the user to connect their TikTok account (or reconnect): they sign in to TikTok and allow Maggie to see their profile and post videos. Give them the link to open; never ask for a password. TikTok is in beta (testers only; until TikTok reviews our app, videos post as 'Only me' and the account must be private). TikTok posts are short videos (5-30 seconds) that the user approves one by one.",
11484
+ parameters: none,
11485
+ group: "social",
11486
+ need: write5,
11487
+ risk: "W1",
11488
+ gates: ["VER"],
11489
+ run: async (ctx) => {
11490
+ const out = await ws(ctx).connectTikTok("close");
11491
+ return { url: out.url, expires_in_seconds: out.expires_in_seconds };
11492
+ }
11493
+ }),
11494
+ tool({
11495
+ name: "tiktok_creator",
11496
+ description: "What the user's TikTok account allows right now (TikTok asks to check before every post): its name and @username, who a video can be shown to (privacy_options), whether comments / Duet / Stitch are turned off on the account, the longest video it can post, and whether it can post now (problem). Read it before asking the user the TikTok settings of a post.",
11497
+ parameters: none,
11498
+ group: "social",
11499
+ need: read5,
11500
+ risk: "R0",
11501
+ run: async (ctx) => ws(ctx).tiktokCreator()
11502
+ }),
11503
+ tool({
11504
+ name: "social_tiktok_set",
11505
+ description: "Set a TikTok post's settings, ONLY as the user chose them (TikTok's rules: never pick for them, nothing is preset): privacy (one of the account's privacy_options from tiktok_creator: PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLY), allow_comment / allow_duet / allow_stitch (off unless the user turns them on; not possible when the account turned them off), disclose (the video promotes a brand, product or service) with your_brand (their own: labelled 'Promotional content') and / or branded (a third party: labelled 'Paid partnership'; can't be SELF_ONLY), is_aigc (AI-generated content label). The caption is the post's text (social_update_copy). Before the user approves, tell them: 'By posting, you agree to TikTok's Music Usage Confirmation' (with branded content: 'TikTok's Branded Content Policy and Music Usage Confirmation'). Preview first.",
11506
+ parameters: {
11507
+ type: "object",
11508
+ properties: {
11509
+ id: { type: "integer", description: "A TikTok post (from social_list)" },
11510
+ privacy: { type: "string", enum: TIKTOK_PRIVACY },
11511
+ allow_comment: { type: "boolean" },
11512
+ allow_duet: { type: "boolean" },
11513
+ allow_stitch: { type: "boolean" },
11514
+ disclose: { type: "boolean" },
11515
+ your_brand: { type: "boolean" },
11516
+ branded: { type: "boolean" },
11517
+ is_aigc: { type: "boolean" }
11518
+ },
11519
+ required: ["id"]
11520
+ },
11521
+ group: "social",
11522
+ need: write5,
11523
+ risk: "W1",
11524
+ gates: ["VER"],
11525
+ run: async (ctx, { id, privacy, ...rest }) => {
11526
+ const answers = Object.fromEntries(
11527
+ Object.entries(rest).filter(([, v]) => v !== void 0)
11528
+ );
11529
+ if (privacy) answers.privacy = privacy;
11530
+ const out = await ws(ctx).setSocialTikTok(Number(id), answers);
11531
+ return item(out);
11532
+ }
11533
+ }),
11534
+ tool({
11535
+ name: "social_video_add",
11536
+ description: "Make a YouTube and / or TikTok post (channels; default youtube) from a video: a DIRECT link to a video file (an .mp4 on their website, a public Dropbox link), never a YouTube / TikTok / Instagram page (we never download those; tell the user to upload the video in the app instead). The video must be 5-30 seconds: give start/end (seconds) to use a part of a longer one. vertical: make a wide video vertical (a Short) cropped at left, middle or right. Maggie writes YouTube's title, description and tags, or TikTok's caption, from the video and the note (one AI generation per channel) unless write is false. Each post waits in Review: video posts are always approved by the user. YouTube asks whether it's made for kids; TikTok asks who can view it and the other TikTok settings (social_tiktok_set, as the user chooses). is_aigc: TikTok's AI-generated label, only when the user says the video was made with AI. Preview first.",
11537
+ parameters: {
11538
+ type: "object",
11539
+ properties: {
11540
+ url: { type: "string", description: "Direct https link to the video file" },
11541
+ channels: {
11542
+ type: "array",
11543
+ items: { type: "string", enum: [...VIDEO_KEYS] },
11544
+ description: "youtube, tiktok or both (one post each); default youtube"
11545
+ },
11546
+ is_aigc: { type: "boolean", description: "TikTok: the video is AI-generated (the user said so)" },
11547
+ note: { type: "string", description: "What the video is about, in the user's words" },
11548
+ write: { type: "boolean", description: "Let Maggie write the details (default true)" },
11549
+ start: { type: "number", description: "Start of the part to use, seconds" },
11550
+ end: { type: "number", description: "End of the part to use, seconds" },
11551
+ vertical: { type: "string", enum: ["left", "middle", "right"] }
11552
+ },
11553
+ required: ["url"]
11554
+ },
11555
+ group: "social",
11556
+ need: write5,
11557
+ risk: "W2",
11558
+ gates: ["VER", "LLM"],
11559
+ run: async (ctx, a) => {
11560
+ const d = ws(ctx);
11561
+ const edit = {
11562
+ ...a.start != null ? { start: Number(a.start) } : {},
11563
+ ...a.end != null ? { end: Number(a.end) } : {},
11564
+ ...a.vertical ? { aspect: "9:16", crop_x: a.vertical === "left" ? 0 : a.vertical === "right" ? 1 : 0.5 } : {}
11565
+ };
11566
+ let v = await d.videoFromLink(String(a.url), Object.keys(edit).length ? edit : void 0);
11567
+ const until = Date.now() + 15e4;
11568
+ while ((v.status === "checking" || v.status === "uploading") && Date.now() < until) {
11569
+ await new Promise((r) => setTimeout(r, 3e3));
11570
+ v = await d.video(v.id);
11571
+ }
11572
+ if (v.status !== "ready")
11573
+ return { ok: false, video: { status: v.status, error: v.error, seconds: v.source_duration } };
11574
+ const wanted = (a.channels ?? ["youtube"]).filter(
11575
+ (c) => VIDEO_KEYS.includes(c)
11576
+ );
11577
+ const posts = [];
11578
+ for (const channel of wanted.length ? wanted : ["youtube"]) {
11579
+ posts.push(
11580
+ await d.postFromVideo(v.id, {
11581
+ channel,
11582
+ note: a.note ?? "",
11583
+ write: a.write !== false,
11584
+ ...channel === "tiktok" && a.is_aigc != null ? { is_aigc: a.is_aigc } : {}
11585
+ })
11586
+ );
11587
+ }
11588
+ return {
11589
+ ok: true,
11590
+ ...posts.length === 1 ? { post: item(posts[0]) } : { posts: posts.map(item) },
11591
+ next: [
11592
+ posts.some((p) => p.provider === "youtube") ? "YouTube: show the title and description; ask whether it's made for kids and which visibility, then social_youtube_set." : "",
11593
+ posts.some((p) => p.provider === "tiktok") ? "TikTok: show the caption; read tiktok_creator and ask who can view it, whether to allow comments / Duet / Stitch and whether it promotes a brand, then social_tiktok_set exactly as they say; tell them the Music Usage Confirmation line." : "",
11594
+ "The user approves each post."
11595
+ ].filter(Boolean).join(" ")
11596
+ };
11597
+ }
11598
+ }),
11599
+ tool({
11600
+ name: "social_youtube_set",
11601
+ description: "Change a YouTube post that hasn't gone out: title (at most 100 characters, aim 40-60), tags, visibility (public, unlisted, private), made_for_kids (YouTube asks for every video: ask the user, never guess) or synthetic (realistic AI-made or altered content). The description is the post's text (social_update_copy). Preview first.",
11602
+ parameters: {
11603
+ type: "object",
11604
+ properties: {
11605
+ id: { type: "integer", description: "A YouTube post (from social_list)" },
11606
+ title: { type: "string" },
11607
+ tags: { type: "array", items: { type: "string" } },
11608
+ privacy: { type: "string", enum: ["public", "unlisted", "private"] },
11609
+ made_for_kids: { type: "boolean" },
11610
+ synthetic: { type: "boolean" }
11611
+ },
11612
+ required: ["id"]
11613
+ },
11614
+ group: "social",
11615
+ need: write5,
11616
+ risk: "W1",
11617
+ gates: ["VER"],
11618
+ run: async (ctx, { id, ...rest }) => {
11619
+ const answers = Object.fromEntries(
11620
+ Object.entries(rest).filter(([, v]) => v !== void 0)
11621
+ );
11622
+ const out = await ws(ctx).setSocialYouTube(Number(id), answers);
11623
+ return item(out);
11624
+ }
11625
+ }),
11626
+ tool({
11627
+ name: "youtube_settings",
11628
+ description: "The user's YouTube defaults for new posts on this website: visibility, made_for_kids (null = asked each time), category and altered content.",
11629
+ parameters: none,
11630
+ group: "social",
11631
+ need: read5,
11632
+ risk: "R0",
11633
+ run: async (ctx) => ws(ctx).youtubeSettings()
11634
+ }),
11635
+ tool({
11636
+ name: "youtube_settings_set",
11637
+ description: "Change the YouTube defaults for new posts: privacy (public, unlisted, private), made_for_kids (yes / no for every video, or ask per video) and synthetic. Only what the user says. Preview first.",
11638
+ parameters: {
11639
+ type: "object",
11640
+ properties: {
11641
+ privacy: { type: "string", enum: ["public", "unlisted", "private"] },
11642
+ made_for_kids: {
11643
+ type: "string",
11644
+ enum: ["yes", "no", "ask"],
11645
+ description: "yes / no for every new video, or ask (answer per video)"
11646
+ },
11647
+ synthetic: { type: "boolean" }
11648
+ }
11649
+ },
11650
+ group: "social",
11651
+ need: write5,
11652
+ risk: "W1",
11653
+ gates: ["VER"],
11654
+ run: async (ctx, a) => ws(ctx).setYouTubeSettings({
11655
+ ...a.privacy ? { privacy: a.privacy } : {},
11656
+ ...a.made_for_kids ? { made_for_kids: a.made_for_kids === "ask" ? null : a.made_for_kids === "yes" } : {},
11657
+ ...a.synthetic != null ? { synthetic: a.synthetic } : {}
11658
+ })
11659
+ }),
11660
+ tool({
11661
+ name: "google_business_performance",
11662
+ description: "How the website's Google Business Profile is doing: views on Google Search and Maps, website clicks, calls and direction requests for the last 28 days next to the 28 before (Google's numbers lag 2-3 days). Use it when the user asks how their Google listing or local presence is doing, or to show what Google posts are worth. Compare with the previous 28 days; don't invent reasons for changes.",
11663
+ parameters: {
11664
+ type: "object",
11665
+ properties: { refresh: { type: "boolean", description: "Read Google again now (else up to 6 hours old)" } }
11666
+ },
11667
+ group: "social",
11668
+ need: read5,
11669
+ risk: "R0",
11670
+ run: async (ctx, { refresh }) => ws(ctx).googleBusinessPerformance(refresh === true)
11671
+ }),
11672
+ tool({
11673
+ name: "social_google_post_type",
11674
+ description: "Make a Google Business post that hasn't gone out an EVENT (an open day, a workshop: title up to 58 characters, start and end dates YYYY-MM-DD, optional times HH:MM) or an OFFER (title, dates, optional coupon code, terms and redeem link; Google adds a View offer button), or back to STANDARD (an update). Offers are refused for regulated categories (finance, health, alcohol, gambling) and hotels: use an update or an event. Only real dates and offers the user gave. Preview first.",
11675
+ parameters: {
11676
+ type: "object",
11677
+ properties: {
11678
+ id: { type: "integer", description: "A Google Business post (from social_list)" },
11679
+ type: { type: "string", enum: ["STANDARD", "EVENT", "OFFER"] },
11680
+ title: { type: "string", description: "Event or offer title, up to 58 characters" },
11681
+ start: { type: "string", description: "YYYY-MM-DD" },
11682
+ end: { type: "string", description: "YYYY-MM-DD (defaults to start)" },
11683
+ start_time: { type: "string", description: "HH:MM (optional)" },
11684
+ end_time: { type: "string", description: "HH:MM (optional)" },
11685
+ coupon: { type: "string", description: "Offer code (optional)" },
11686
+ terms: { type: "string", description: "Offer terms (optional)" },
11687
+ redeem_url: { type: "string", description: "Where to redeem the offer online (optional)" }
11688
+ },
11689
+ required: ["id", "type"]
11690
+ },
11691
+ group: "social",
11692
+ need: write5,
11693
+ risk: "W1",
11694
+ gates: ["VER"],
11695
+ run: async (ctx, a) => {
11696
+ const type = String(a.type).toUpperCase();
11697
+ const body = type === "STANDARD" ? { type: "STANDARD" } : {
11698
+ type,
11699
+ title: String(a.title ?? ""),
11700
+ start: String(a.start ?? ""),
11701
+ ...a.end ? { end: a.end } : {},
11702
+ ...a.start_time ? { start_time: a.start_time } : {},
11703
+ ...a.end_time ? { end_time: a.end_time } : {},
11704
+ ...a.coupon ? { coupon: a.coupon } : {},
11705
+ ...a.terms ? { terms: a.terms } : {},
11706
+ ...a.redeem_url ? { redeem_url: a.redeem_url } : {}
11707
+ };
11708
+ const out = await ws(ctx).setSocialGooglePost(Number(a.id), body);
11709
+ return { id: out.id, google_post: out.google_post ?? null };
11710
+ }
11711
+ }),
11356
11712
  tool({
11357
11713
  name: "instagram_connect_link",
11358
11714
  description: "A link for the user to connect Instagram (or reconnect). Instagram connects through the Facebook sign-in: only an Instagram professional account (Business or Creator) linked to one of their Facebook Pages can be posted to, so tell them that first. Give them the link to open; never ask for a password. Every Instagram post goes out with its picture (no links in it).",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmo-so/mcp",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "CMO.so's MCP server for Claude Desktop, Cursor, VS Code and other MCP clients: manage your websites, blog posts, LinkedIn queue and results with Maggie.",
5
5
  "type": "module",
6
6
  "license": "MIT",