@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.
- package/dist/cmo-mcp.mjs +361 -5
- 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.
|
|
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 = [
|
|
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:
|
|
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
|
+
"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",
|