@cmo-so/mcp 0.3.0 → 0.4.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 +246 -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.4.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,15 @@ 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
+ ];
7534
7542
  function addWebsite(api2, url) {
7535
7543
  return api2.request("/v1/domains", {
7536
7544
  method: "POST",
@@ -7763,7 +7771,24 @@ function domainApi(api2, domain) {
7763
7771
  `${base}/social/google_business/location`,
7764
7772
  { connection_id: connectionId }
7765
7773
  ),
7774
+ /** How the Google Business Profile is doing: the last 28 days and the 28 before (Google lags 2-3 days). */
7775
+ googleBusinessPerformance: (refresh = false) => api2.request(`${base}/social/google_business/performance${refresh ? "?refresh=true" : ""}`),
7766
7776
  disconnectGoogleBusiness: (connectionId) => api2.request(`${base}/social/google_business/${connectionId}`, { method: "DELETE" }),
7777
+ /** A Google sign-in link for this website's YouTube channel (docs/prd/youtube.md §7); back with ?youtube=connected
7778
+ * (or none when the Google account has no channel, error&message=…). */
7779
+ connectYouTube: (returnTo = "close") => post(`${base}/social/youtube/connect`, { return_to: returnTo }),
7780
+ /** Your YouTube channels here (name, @handle, picture, subscribers), and the one this website posts to. */
7781
+ youtubeChannels: () => api2.request(
7782
+ `${base}/social/youtube/channels`
7783
+ ),
7784
+ chooseYouTubeChannel: (connectionId) => put(
7785
+ `${base}/social/youtube/channel`,
7786
+ {
7787
+ connection_id: connectionId
7788
+ }
7789
+ ),
7790
+ /** Disconnect: the token is deleted and Google's access revoked (YouTube's policy). */
7791
+ disconnectYouTube: (connectionId) => api2.request(`${base}/social/youtube/${connectionId}`, { method: "DELETE" }),
7767
7792
  /** One channel's own pause switch (pauseSocial / resumeSocial hold every channel). */
7768
7793
  setChannelPaused: (channel, paused) => post(`${base}/social/channels/${channel}/${paused ? "pause" : "resume"}`),
7769
7794
  /** How often one channel posts. */
@@ -7928,7 +7953,34 @@ function domainApi(api2, domain) {
7928
7953
  headers: { "Content-Type": "application/json" },
7929
7954
  body: JSON.stringify({ cta_type: ctaType })
7930
7955
  }),
7956
+ /** Make a Google Business post an Event or Offer (or back to an update with { type: "STANDARD" }). */
7957
+ setSocialGooglePost: (id, googlePost) => api2.request(`${base}/social/${id}`, {
7958
+ method: "PATCH",
7959
+ headers: { "Content-Type": "application/json" },
7960
+ body: JSON.stringify({ google_post: googlePost })
7961
+ }),
7931
7962
  resumeSocial: () => post(`${base}/social/resume`),
7963
+ /** Start a video upload: PUT each part_size slice of the file to its url, keep the ETags, then completeVideoUpload. */
7964
+ startVideoUpload: (file) => post(
7965
+ `${base}/social/video/upload`,
7966
+ file
7967
+ ),
7968
+ completeVideoUpload: (videoId, parts, edit) => post(`${base}/social/video/${videoId}/complete`, { parts, edit }),
7969
+ /** A direct link to a video file (not a YouTube / TikTok page). */
7970
+ videoFromLink: (url, edit) => post(`${base}/social/video/link`, { url, edit }),
7971
+ video: (videoId) => api2.request(`${base}/social/video/${videoId}`),
7972
+ /** Another part or framing; prepared again in the background. */
7973
+ editVideo: (videoId, edit) => post(`${base}/social/video/${videoId}/edit`, { edit }),
7974
+ /** A YouTube post from a ready video, waiting in Review; Maggie writes the details unless write is false. */
7975
+ postFromVideo: (videoId, opts = {}) => post(`${base}/social/video/${videoId}/post`, opts),
7976
+ youtubeSettings: () => api2.request(`${base}/social/youtube/settings`),
7977
+ setYouTubeSettings: (s) => put(`${base}/social/youtube/settings`, s),
7978
+ /** A YouTube post's title, tags, visibility, made-for-kids or altered-content answer. */
7979
+ setSocialYouTube: (id, answers) => api2.request(`${base}/social/${id}`, {
7980
+ method: "PATCH",
7981
+ headers: { "Content-Type": "application/json" },
7982
+ body: JSON.stringify({ youtube: answers })
7983
+ }),
7932
7984
  pauseAutopilot: () => post(`${base}/autopilot/pause`),
7933
7985
  resumeAutopilot: () => post(`${base}/autopilot/resume`),
7934
7986
  // Reports (docs/18 §6): report:read with the user's own key.
@@ -8349,6 +8401,22 @@ var confirmCopy = {
8349
8401
  en: (a) => `Bring back LinkedIn post #${a.id}.`,
8350
8402
  "zh-Hant": (a) => `\u6062\u5FA9 LinkedIn \u8CBC\u6587 #${a.id}\u3002`
8351
8403
  },
8404
+ youtube_connect_link: {
8405
+ en: () => "Make a link to connect your YouTube channel.",
8406
+ "zh-Hant": () => "\u7522\u751F\u9023\u63A5\u4F60 YouTube \u983B\u9053\u7684\u9023\u7D50\u3002"
8407
+ },
8408
+ social_video_add: {
8409
+ en: (a) => `Make a YouTube post from ${a.url}${a.write === false ? "" : " (Maggie writes the title and description: one AI generation)"}; it waits for your OK.`,
8410
+ "zh-Hant": (a) => `\u7528 ${a.url} \u505A\u4E00\u5247 YouTube \u5F71\u7247\u8CBC\u6587${a.write === false ? "" : "\uFF08Maggie \u5BEB\u6A19\u984C\u548C\u8AAA\u660E\uFF1A\u7528\u4E00\u6B21 AI \u751F\u6210\uFF09"}\uFF0C\u7B49\u4F60\u78BA\u8A8D\u5F8C\u624D\u767C\u3002`
8411
+ },
8412
+ social_youtube_set: {
8413
+ en: (a) => `Change YouTube post #${a.id}: ${keys(a).filter((k) => k !== "id").join(", ") || "its details"}.`,
8414
+ "zh-Hant": (a) => `\u4FEE\u6539 YouTube \u8CBC\u6587 #${a.id}\uFF1A${keys(a).filter((k) => k !== "id").join("\u3001") || "\u7D30\u7BC0"}\u3002`
8415
+ },
8416
+ youtube_settings_set: {
8417
+ en: (a) => `Change your YouTube defaults: ${keys(a).join(", ") || "nothing"}.`,
8418
+ "zh-Hant": (a) => `\u4FEE\u6539 YouTube \u9810\u8A2D\uFF1A${keys(a).join("\u3001") || "\u7121"}\u3002`
8419
+ },
8352
8420
  social_image_new: {
8353
8421
  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
8422
  "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 +10430,11 @@ var read5 = { resource: "autopilot", level: "read" };
10362
10430
  var write5 = { resource: "autopilot", level: "write" };
10363
10431
  var moves2 = ["up", "down", "top"];
10364
10432
  var CHANNEL_KEYS = ["linkedin", "facebook", "instagram", "google_business"];
10433
+ var ALL_CHANNEL_KEYS = [...CHANNEL_KEYS, "youtube"];
10365
10434
  var channelsParam = {
10366
10435
  type: "array",
10367
10436
  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."
10437
+ 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 takes a video: use social_video_add."
10369
10438
  };
10370
10439
  var channelsOf = (value) => {
10371
10440
  const list2 = Array.isArray(value) ? value.map((v) => String(v).toLowerCase()).filter((v) => CHANNEL_KEYS.includes(v)) : [];
@@ -10388,7 +10457,11 @@ var item = (i) => ({
10388
10457
  campaign: i.campaign ?? null,
10389
10458
  failure: i.failure_message ?? null,
10390
10459
  link: i.provider_permalink ?? null,
10391
- text: clip(i.social_copy, 300)
10460
+ text: clip(i.social_copy, 300),
10461
+ ...i.provider === "youtube" ? {
10462
+ youtube: i.youtube ?? null,
10463
+ video: i.video ? { status: i.video.status, seconds: i.video.duration, shape: i.video.shape, error: i.video.error } : null
10464
+ } : {}
10392
10465
  });
10393
10466
  var socialTools = [
10394
10467
  tool({
@@ -10797,7 +10870,7 @@ var socialTools = [
10797
10870
  description: "Pause or resume the user's social posting: one channel (linkedin or facebook), or every channel when no channel is given.",
10798
10871
  parameters: {
10799
10872
  type: "object",
10800
- properties: { paused: { type: "boolean" }, channel: { type: "string", enum: CHANNEL_KEYS } },
10873
+ properties: { paused: { type: "boolean" }, channel: { type: "string", enum: ALL_CHANNEL_KEYS } },
10801
10874
  required: ["paused"]
10802
10875
  },
10803
10876
  group: "social",
@@ -11353,6 +11426,174 @@ var socialTools = [
11353
11426
  return { id: out.id, button: out.cta_type ?? "LEARN_MORE", link: out.cta_url ?? null };
11354
11427
  }
11355
11428
  }),
11429
+ tool({
11430
+ name: "youtube_connect_link",
11431
+ 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.",
11432
+ parameters: none,
11433
+ group: "social",
11434
+ need: write5,
11435
+ risk: "W1",
11436
+ gates: ["VER"],
11437
+ run: async (ctx) => {
11438
+ const out = await ws(ctx).connectYouTube("close");
11439
+ return { url: out.url, expires_in_seconds: out.expires_in_seconds };
11440
+ }
11441
+ }),
11442
+ tool({
11443
+ name: "social_video_add",
11444
+ description: "Make a YouTube post 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 the title, description and tags from the video and the note (one AI generation) unless write is false. The post waits in Review: YouTube posts are always approved by the user, who must also answer whether it's made for kids. Preview first.",
11445
+ parameters: {
11446
+ type: "object",
11447
+ properties: {
11448
+ url: { type: "string", description: "Direct https link to the video file" },
11449
+ note: { type: "string", description: "What the video is about, in the user's words" },
11450
+ write: { type: "boolean", description: "Let Maggie write the details (default true)" },
11451
+ start: { type: "number", description: "Start of the part to use, seconds" },
11452
+ end: { type: "number", description: "End of the part to use, seconds" },
11453
+ vertical: { type: "string", enum: ["left", "middle", "right"] }
11454
+ },
11455
+ required: ["url"]
11456
+ },
11457
+ group: "social",
11458
+ need: write5,
11459
+ risk: "W2",
11460
+ gates: ["VER", "LLM"],
11461
+ run: async (ctx, a) => {
11462
+ const d = ws(ctx);
11463
+ const edit = {
11464
+ ...a.start != null ? { start: Number(a.start) } : {},
11465
+ ...a.end != null ? { end: Number(a.end) } : {},
11466
+ ...a.vertical ? { aspect: "9:16", crop_x: a.vertical === "left" ? 0 : a.vertical === "right" ? 1 : 0.5 } : {}
11467
+ };
11468
+ let v = await d.videoFromLink(String(a.url), Object.keys(edit).length ? edit : void 0);
11469
+ const until = Date.now() + 15e4;
11470
+ while ((v.status === "checking" || v.status === "uploading") && Date.now() < until) {
11471
+ await new Promise((r) => setTimeout(r, 3e3));
11472
+ v = await d.video(v.id);
11473
+ }
11474
+ if (v.status !== "ready")
11475
+ return { ok: false, video: { status: v.status, error: v.error, seconds: v.source_duration } };
11476
+ const post = await d.postFromVideo(v.id, { note: a.note ?? "", write: a.write !== false });
11477
+ return {
11478
+ ok: true,
11479
+ post: item(post),
11480
+ next: "Show the title and description; ask whether it's made for kids and which visibility, then social_youtube_set; the user approves it."
11481
+ };
11482
+ }
11483
+ }),
11484
+ tool({
11485
+ name: "social_youtube_set",
11486
+ 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.",
11487
+ parameters: {
11488
+ type: "object",
11489
+ properties: {
11490
+ id: { type: "integer", description: "A YouTube post (from social_list)" },
11491
+ title: { type: "string" },
11492
+ tags: { type: "array", items: { type: "string" } },
11493
+ privacy: { type: "string", enum: ["public", "unlisted", "private"] },
11494
+ made_for_kids: { type: "boolean" },
11495
+ synthetic: { type: "boolean" }
11496
+ },
11497
+ required: ["id"]
11498
+ },
11499
+ group: "social",
11500
+ need: write5,
11501
+ risk: "W1",
11502
+ gates: ["VER"],
11503
+ run: async (ctx, { id, ...rest }) => {
11504
+ const answers = Object.fromEntries(
11505
+ Object.entries(rest).filter(([, v]) => v !== void 0)
11506
+ );
11507
+ const out = await ws(ctx).setSocialYouTube(Number(id), answers);
11508
+ return item(out);
11509
+ }
11510
+ }),
11511
+ tool({
11512
+ name: "youtube_settings",
11513
+ description: "The user's YouTube defaults for new posts on this website: visibility, made_for_kids (null = asked each time), category and altered content.",
11514
+ parameters: none,
11515
+ group: "social",
11516
+ need: read5,
11517
+ risk: "R0",
11518
+ run: async (ctx) => ws(ctx).youtubeSettings()
11519
+ }),
11520
+ tool({
11521
+ name: "youtube_settings_set",
11522
+ 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.",
11523
+ parameters: {
11524
+ type: "object",
11525
+ properties: {
11526
+ privacy: { type: "string", enum: ["public", "unlisted", "private"] },
11527
+ made_for_kids: {
11528
+ type: "string",
11529
+ enum: ["yes", "no", "ask"],
11530
+ description: "yes / no for every new video, or ask (answer per video)"
11531
+ },
11532
+ synthetic: { type: "boolean" }
11533
+ }
11534
+ },
11535
+ group: "social",
11536
+ need: write5,
11537
+ risk: "W1",
11538
+ gates: ["VER"],
11539
+ run: async (ctx, a) => ws(ctx).setYouTubeSettings({
11540
+ ...a.privacy ? { privacy: a.privacy } : {},
11541
+ ...a.made_for_kids ? { made_for_kids: a.made_for_kids === "ask" ? null : a.made_for_kids === "yes" } : {},
11542
+ ...a.synthetic != null ? { synthetic: a.synthetic } : {}
11543
+ })
11544
+ }),
11545
+ tool({
11546
+ name: "google_business_performance",
11547
+ 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.",
11548
+ parameters: {
11549
+ type: "object",
11550
+ properties: { refresh: { type: "boolean", description: "Read Google again now (else up to 6 hours old)" } }
11551
+ },
11552
+ group: "social",
11553
+ need: read5,
11554
+ risk: "R0",
11555
+ run: async (ctx, { refresh }) => ws(ctx).googleBusinessPerformance(refresh === true)
11556
+ }),
11557
+ tool({
11558
+ name: "social_google_post_type",
11559
+ 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.",
11560
+ parameters: {
11561
+ type: "object",
11562
+ properties: {
11563
+ id: { type: "integer", description: "A Google Business post (from social_list)" },
11564
+ type: { type: "string", enum: ["STANDARD", "EVENT", "OFFER"] },
11565
+ title: { type: "string", description: "Event or offer title, up to 58 characters" },
11566
+ start: { type: "string", description: "YYYY-MM-DD" },
11567
+ end: { type: "string", description: "YYYY-MM-DD (defaults to start)" },
11568
+ start_time: { type: "string", description: "HH:MM (optional)" },
11569
+ end_time: { type: "string", description: "HH:MM (optional)" },
11570
+ coupon: { type: "string", description: "Offer code (optional)" },
11571
+ terms: { type: "string", description: "Offer terms (optional)" },
11572
+ redeem_url: { type: "string", description: "Where to redeem the offer online (optional)" }
11573
+ },
11574
+ required: ["id", "type"]
11575
+ },
11576
+ group: "social",
11577
+ need: write5,
11578
+ risk: "W1",
11579
+ gates: ["VER"],
11580
+ run: async (ctx, a) => {
11581
+ const type = String(a.type).toUpperCase();
11582
+ const body = type === "STANDARD" ? { type: "STANDARD" } : {
11583
+ type,
11584
+ title: String(a.title ?? ""),
11585
+ start: String(a.start ?? ""),
11586
+ ...a.end ? { end: a.end } : {},
11587
+ ...a.start_time ? { start_time: a.start_time } : {},
11588
+ ...a.end_time ? { end_time: a.end_time } : {},
11589
+ ...a.coupon ? { coupon: a.coupon } : {},
11590
+ ...a.terms ? { terms: a.terms } : {},
11591
+ ...a.redeem_url ? { redeem_url: a.redeem_url } : {}
11592
+ };
11593
+ const out = await ws(ctx).setSocialGooglePost(Number(a.id), body);
11594
+ return { id: out.id, google_post: out.google_post ?? null };
11595
+ }
11596
+ }),
11356
11597
  tool({
11357
11598
  name: "instagram_connect_link",
11358
11599
  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.4.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",