@cmo-so/mcp 0.7.6 → 0.8.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 +602 -16
  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.7.6",
7202
+ version: "0.8.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",
@@ -7257,6 +7257,9 @@ import { createRequire as __cr } from 'node:module'; const require = __cr(import
7257
7257
  // src/mcp.ts
7258
7258
  import { randomBytes } from "node:crypto";
7259
7259
 
7260
+ // ../../packages/mcp/src/index.ts
7261
+ import { randomUUID as randomUUID2 } from "node:crypto";
7262
+
7260
7263
  // ../../packages/api-client/src/errors.ts
7261
7264
  var OAuthError = class extends Error {
7262
7265
  constructor(error2, description2, status) {
@@ -7410,6 +7413,23 @@ function createApi({ baseUrl, session: session2, fetch: fetchImpl = globalThis.f
7410
7413
  apps: () => request("/v1/me/apps"),
7411
7414
  revokeApp: (grantId) => request(`/v1/me/apps/${encodeURIComponent(grantId)}`, { method: "DELETE" }),
7412
7415
  invoices: () => request("/v1/me/invoices"),
7416
+ /** Standing permissions: one of your keys may run some writes on one website without a preview, up to a daily cap. */
7417
+ standing: () => request(
7418
+ "/v1/me/standing"
7419
+ ),
7420
+ /** From another key than `key_id` (409/422 otherwise); replaces any earlier one for that key and website. */
7421
+ grantStanding: (p) => request("/v1/me/standing", {
7422
+ method: "POST",
7423
+ headers: { "Content-Type": "application/json" },
7424
+ body: JSON.stringify(p)
7425
+ }),
7426
+ revokeStanding: (id) => request(`/v1/me/standing/${id}`, { method: "DELETE" }),
7427
+ /** The MCP, just before a write: records one use when the key's permission covers it. */
7428
+ claimStanding: (p) => request("/v1/me/standing/claim", {
7429
+ method: "POST",
7430
+ headers: { "Content-Type": "application/json" },
7431
+ body: JSON.stringify(p)
7432
+ }),
7413
7433
  /**
7414
7434
  * Take a website off your own list (archives your claim; reversible on ai.cmo.so). Nothing changes for
7415
7435
  * anyone else; a team member also leaves the team.
@@ -7993,9 +8013,7 @@ function domainApi(api2, domain) {
7993
8013
  /** Disconnect the website's LinkedIn account. */
7994
8014
  disconnectLinkedIn: (connectionId) => api2.request(`${base}/social/linkedin/${connectionId}`, { method: "DELETE" }),
7995
8015
  /** Published articles a LinkedIn post can be made from. */
7996
- socialSources: (search = "") => api2.request(
7997
- `${base}/social/sources?search=${encodeURIComponent(search)}`
7998
- ),
8016
+ socialSources: (search = "") => api2.request(`${base}/social/sources?search=${encodeURIComponent(search)}`),
7999
8017
  /** Maggie writes a post about one article for each channel; `failed` lists a channel that could not be written while the others were. */
8000
8018
  socialFromPost: (sourcePostId, channels) => post(`${base}/social/from-post`, {
8001
8019
  source_post_id: sourcePostId,
@@ -8202,9 +8220,9 @@ function domainApi(api2, domain) {
8202
8220
  /** The last time posts delivered by API pull were found in the website's sitemap (gives them their address). */
8203
8221
  sitemapMatch: () => api2.request(`${base}/rewrites/sitemap-match`),
8204
8222
  /** Read the sitemap (found on its own when blank) and match API-pull posts to their live address. */
8205
- runSitemapMatch: (sitemapUrl) => post(
8223
+ runSitemapMatch: (sitemapUrl2) => post(
8206
8224
  `${base}/rewrites/sitemap-match`,
8207
- sitemapUrl ? { sitemap_url: sitemapUrl } : {}
8225
+ sitemapUrl2 ? { sitemap_url: sitemapUrl2 } : {}
8208
8226
  ),
8209
8227
  rewritePolicy: () => api2.request(`${base}/rewrite-policy`),
8210
8228
  setRewritePolicy: (p) => put(`${base}/rewrite-policy`, p),
@@ -8221,7 +8239,37 @@ function domainApi(api2, domain) {
8221
8239
  startPromptCheck: (prompt) => post(`${base}/visibility/prompt-check`, { prompt }),
8222
8240
  promptCheck: (taskId) => api2.request(`${base}/visibility/prompt-check/${encodeURIComponent(taskId)}`),
8223
8241
  report: (range = "30d") => api2.request(`${base}/report?range=${range}`),
8224
- platform: () => api2.request(`${base}/platform`)
8242
+ platform: () => api2.request(`${base}/platform`),
8243
+ // Ops Autopilot (cmo-so docs/prd/ops-autopilot.md): Maggie's daily operations on the website.
8244
+ opsAutopilot: () => api2.request(`${base}/ops-autopilot`),
8245
+ setOpsAutopilot: (s) => put(`${base}/ops-autopilot`, s),
8246
+ // The report inbox: Maggie's reports and agents' reports.
8247
+ reports: (q2 = {}) => {
8248
+ const params = new URLSearchParams();
8249
+ if (q2.kind) params.set("kind", q2.kind);
8250
+ if (q2.unread) params.set("unread", "true");
8251
+ if (q2.limit) params.set("limit", String(q2.limit));
8252
+ const qs = params.toString();
8253
+ return api2.request(`${base}/reports${qs ? `?${qs}` : ""}`);
8254
+ },
8255
+ inboxReport: (id) => api2.request(`${base}/reports/${id}`),
8256
+ saveReport: (r) => post(`${base}/reports`, r),
8257
+ readAllReports: () => post(`${base}/reports/read-all`, {}),
8258
+ // Ops Autopilot: ideas the weekly clean-up archived (restorable), post health, Google reviews.
8259
+ ideaCleanup: (limit = 50) => api2.request(`${base}/idea-cleanup?limit=${limit}`),
8260
+ restoreIdeaCleanup: (id) => post(`${base}/idea-cleanup/${id}/restore`, {}),
8261
+ postHealth: (limit = 30) => api2.request(`${base}/post-health?limit=${limit}`),
8262
+ googleReviews: (q2 = {}) => {
8263
+ const params = new URLSearchParams();
8264
+ if (q2.status) params.set("status", q2.status);
8265
+ if (q2.limit) params.set("limit", String(q2.limit));
8266
+ const qs = params.toString();
8267
+ return api2.request(
8268
+ `${base}/google-business/reviews${qs ? `?${qs}` : ""}`
8269
+ );
8270
+ },
8271
+ redraftGoogleReview: (id) => post(`${base}/google-business/reviews/${id}/draft`, {}),
8272
+ replyGoogleReview: (id, text3) => post(`${base}/google-business/reviews/${id}/reply`, text3 === void 0 ? {} : { text: text3 })
8225
8273
  };
8226
8274
  }
8227
8275
 
@@ -8341,8 +8389,84 @@ function createSession({ oauth: oauth2, store, skewMs = 6e4, now = Date.now, onS
8341
8389
  };
8342
8390
  }
8343
8391
 
8392
+ // ../../packages/api-client/src/sitemaps.ts
8393
+ var SITEMAPS_MAX = 20;
8394
+ var SITEMAP_LABEL_MAX = 80;
8395
+ var SITEMAP_OWNERS = ["own", "competitor", "other"];
8396
+ var SITEMAP_USES = {
8397
+ own: ["post_ideas", "channel_ideas", "internal_links", "post_matching"],
8398
+ competitor: ["post_ideas", "channel_ideas"],
8399
+ other: ["post_ideas", "channel_ideas"]
8400
+ };
8401
+ function sitemapUrl(input) {
8402
+ let v = input.trim();
8403
+ if (!v || /^[a-z][a-z0-9+.-]*:(?!\/\/|\d)/i.test(v)) return null;
8404
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(v)) v = `https://${v}`;
8405
+ let u;
8406
+ try {
8407
+ u = new URL(v);
8408
+ } catch {
8409
+ return null;
8410
+ }
8411
+ if (u.protocol !== "http:" && u.protocol !== "https:" || u.username || u.password) return null;
8412
+ if (!u.hostname.includes(".") || /\s/.test(v)) return null;
8413
+ u.protocol = "https:";
8414
+ return u.toString();
8415
+ }
8416
+ function sitemapUses(owner, uses) {
8417
+ const allowed2 = SITEMAP_USES[owner];
8418
+ const kept = allowed2.filter((u) => uses?.includes(u));
8419
+ return kept.length ? kept : [...allowed2];
8420
+ }
8421
+ function onWebsite(url, domain) {
8422
+ let host;
8423
+ try {
8424
+ host = new URL(url).hostname.toLowerCase();
8425
+ } catch {
8426
+ return false;
8427
+ }
8428
+ const d = domain.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "").replace(/^www\./, "");
8429
+ return Boolean(d) && (host === d || host.endsWith(`.${d}`));
8430
+ }
8431
+ function cleanSitemap(entry, domain) {
8432
+ const url = sitemapUrl(entry.url ?? "");
8433
+ if (!url) return "The sitemap must be a web address starting with http:// or https://.";
8434
+ let owner = SITEMAP_OWNERS.includes(entry.owner) ? entry.owner : "other";
8435
+ if (owner === "own" && domain && !onWebsite(url, domain)) owner = "other";
8436
+ const label = (entry.label ?? "").trim().slice(0, SITEMAP_LABEL_MAX);
8437
+ const out = { ...entry, url, owner, label: label || null, uses: sitemapUses(owner, entry.uses) };
8438
+ if (!out.id) delete out.id;
8439
+ return out;
8440
+ }
8441
+ function checkSitemaps(list2) {
8442
+ if (list2.length > SITEMAPS_MAX) return `Up to ${SITEMAPS_MAX} sitemaps. Remove one first.`;
8443
+ const seen = /* @__PURE__ */ new Set();
8444
+ for (const s of list2) {
8445
+ const k = s.url.trim().toLowerCase();
8446
+ if (seen.has(k)) return `${s.url} is already in the list.`;
8447
+ seen.add(k);
8448
+ }
8449
+ return null;
8450
+ }
8451
+ function sitemapCheckLine(check) {
8452
+ if (!check || check.status === "unchecked") return "Checked when you save";
8453
+ if (check.status === "error") return check.error?.trim() || "Couldn't read this sitemap.";
8454
+ const n = check.url_count;
8455
+ const found = n == null ? "Read" : `Found ${n.toLocaleString("en-GB")} ${n === 1 ? "page" : "pages"}`;
8456
+ const at = check.checked_at ? new Date(check.checked_at) : null;
8457
+ const when = at && !Number.isNaN(at.getTime()) ? at.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" }) : null;
8458
+ return when ? `${found} \xB7 checked ${when}` : found;
8459
+ }
8460
+
8344
8461
  // ../../packages/domain/src/copy/maggie.ts
8345
8462
  var q = (s) => `\u201C${String(s ?? "").slice(0, 120)}\u201D`;
8463
+ var CHANNEL_EN = {
8464
+ linkedin: "LinkedIn",
8465
+ facebook: "Facebook",
8466
+ instagram: "Instagram",
8467
+ google_business: "Google Business"
8468
+ };
8469
+ var CHANNEL_ZH = { ...CHANNEL_EN, google_business: "Google \u5546\u5BB6" };
8346
8470
  var named = (title, id) => {
8347
8471
  if (title != null && String(title).trim()) return ` ${q(title)}`;
8348
8472
  const s = String(id ?? "").trim();
@@ -8428,6 +8552,22 @@ var confirmCopy = {
8428
8552
  en: (a) => `Remove the competitor ${q(a.name)}.`,
8429
8553
  "zh-Hant": (a) => `\u522A\u9664\u7AF6\u722D\u5C0D\u624B ${q(a.name)}\u3002`
8430
8554
  },
8555
+ sitemap_upsert: {
8556
+ en: (a) => {
8557
+ const whose = { own: "your site's ", competitor: "a competitor's ", other: "another site's " }[a.owner];
8558
+ const label = a.label ? ` (${q(a.label)})` : "";
8559
+ return a.url ? `Save ${whose ?? "the "}sitemap ${a.url}${label}.` : `Change the sitemap${label}: ${keys(a).filter((k) => k !== "id").join(", ")}.`;
8560
+ },
8561
+ "zh-Hant": (a) => {
8562
+ const whose = { own: "\u81EA\u5DF1\u7DB2\u7AD9\u7684", competitor: "\u7AF6\u722D\u5C0D\u624B\u7684", other: "\u5176\u4ED6\u7DB2\u7AD9\u7684" }[a.owner];
8563
+ const label = a.label ? `\uFF08${q(a.label)}\uFF09` : "";
8564
+ return a.url ? `\u5132\u5B58${whose ?? ""} sitemap ${a.url}${label}\u3002` : `\u4FEE\u6539 sitemap${label}\uFF1A${keys(a).filter((k) => k !== "id").join("\u3001")}\u3002`;
8565
+ }
8566
+ },
8567
+ sitemap_remove: {
8568
+ en: (a) => a.url ?? a.id ? `Remove the sitemap ${a.url ?? a.id}.` : "Remove a sitemap.",
8569
+ "zh-Hant": (a) => a.url ?? a.id ? `\u522A\u9664 sitemap ${a.url ?? a.id}\u3002` : "\u522A\u9664\u4E00\u500B sitemap\u3002"
8570
+ },
8431
8571
  brand_research_update: {
8432
8572
  en: (a) => `Update your market notes: ${keys(a).join(", ") || "nothing"}.`,
8433
8573
  "zh-Hant": (a) => `\u66F4\u65B0\u5E02\u5834\u8CC7\u6599\uFF1A${keys(a).join("\u3001") || "\u6C92\u6709"}\u3002`
@@ -8510,6 +8650,48 @@ var confirmCopy = {
8510
8650
  en: (a) => a.paused ? "Pause publishing blog posts." : "Resume publishing blog posts.",
8511
8651
  "zh-Hant": (a) => a.paused ? "\u66AB\u505C\u767C\u4F48\u90E8\u843D\u683C\u6587\u7AE0\u3002" : "\u6062\u5FA9\u767C\u4F48\u90E8\u843D\u683C\u6587\u7AE0\u3002"
8512
8652
  },
8653
+ ops_autopilot_set: {
8654
+ en: (a) => {
8655
+ const f = Object.entries(a.features ?? {});
8656
+ const on2 = f.filter(([, v]) => v).map(([k]) => k.replace(/_/g, " "));
8657
+ const off = f.filter(([, v]) => !v).map(([k]) => k.replace(/_/g, " "));
8658
+ const parts = [
8659
+ on2.length ? `turn on ${on2.join(", ")}` : "",
8660
+ off.length ? `turn off ${off.join(", ")}` : "",
8661
+ a.weekly_email === true ? "email the Monday report to the owner" : "",
8662
+ a.weekly_email === false ? "stop emailing the Monday report" : ""
8663
+ ].filter(Boolean);
8664
+ return `Ops Autopilot: ${parts.join("; ") || "no change"}.`;
8665
+ },
8666
+ "zh-Hant": (a) => {
8667
+ const f = Object.entries(a.features ?? {});
8668
+ const on2 = f.filter(([, v]) => v).map(([k]) => k);
8669
+ const off = f.filter(([, v]) => !v).map(([k]) => k);
8670
+ const parts = [
8671
+ on2.length ? `\u958B\u555F ${on2.join("\u3001")}` : "",
8672
+ off.length ? `\u95DC\u9589 ${off.join("\u3001")}` : "",
8673
+ a.weekly_email === true ? "\u628A\u9031\u4E00\u5831\u544A\u5BC4\u7D66\u7DB2\u7AD9\u64C1\u6709\u8005" : "",
8674
+ a.weekly_email === false ? "\u4E0D\u518D\u5BC4\u9031\u4E00\u5831\u544A" : ""
8675
+ ].filter(Boolean);
8676
+ return `Ops Autopilot\uFF1A${parts.join("\uFF1B") || "\u6C92\u6709\u8B8A\u66F4"}\u3002`;
8677
+ }
8678
+ },
8679
+ idea_restore_cleanup: {
8680
+ en: (a) => `Bring back the archived idea #${a.id} to the waiting list.`,
8681
+ "zh-Hant": (a) => `\u628A\u5DF2\u6B78\u6A94\u7684 idea #${a.id} \u653E\u56DE\u7B49\u5F85\u5217\u8868\u3002`
8682
+ },
8683
+ gbp_review_draft: {
8684
+ en: (a) => `Write a new reply draft for Google review #${a.id} (one AI generation). Nothing is posted.`,
8685
+ "zh-Hant": (a) => `\u70BA Google \u8A55\u50F9 #${a.id} \u91CD\u5BEB\u56DE\u8986\u8349\u7A3F\uFF08\u7528\u4E00\u6B21 AI \u751F\u6210\uFF09\uFF0C\u4E0D\u6703\u767C\u4F48\u3002`
8686
+ },
8687
+ gbp_review_reply: {
8688
+ en: (a) => `Post ${a.text ? `the reply ${q(a.text)}` : "Maggie's draft"} under Google review #${a.id}. Everyone who sees the review will see it.`,
8689
+ "zh-Hant": (a) => `\u5728 Google \u8A55\u50F9 #${a.id} \u4E0B\u767C\u4F48${a.text ? `\u56DE\u8986 ${q(a.text)}` : " Maggie \u7684\u8349\u7A3F"}\uFF0C\u770B\u5230\u9019\u5247\u8A55\u50F9\u7684\u4EBA\u90FD\u770B\u5F97\u5230\u3002`
8690
+ },
8691
+ report_save: {
8692
+ en: (a) => `Save the report ${q(a.title)} to the website's inbox (the owner gets a notification).`,
8693
+ "zh-Hant": (a) => `\u628A\u5831\u544A ${q(a.title)} \u5B58\u9032\u7DB2\u7AD9\u7684\u6536\u4EF6\u5323\uFF08\u64C1\u6709\u8005\u6703\u6536\u5230\u901A\u77E5\uFF09\u3002`
8694
+ },
8513
8695
  posts_per_day_set: {
8514
8696
  en: (a) => `Publish ${a.posts_per_day} blog post${Number(a.posts_per_day) === 1 ? "" : "s"} a day.`,
8515
8697
  "zh-Hant": (a) => `\u6BCF\u5929\u767C\u4F48 ${a.posts_per_day} \u7BC7\u90E8\u843D\u683C\u6587\u7AE0\u3002`
@@ -8684,8 +8866,8 @@ var confirmCopy = {
8684
8866
  "zh-Hant": () => "\u7522\u751F\u4E00\u500B\u9023\u63A5 LinkedIn \u7684\u9023\u7D50\u3002"
8685
8867
  },
8686
8868
  social_approve_all: {
8687
- en: () => "Approve every LinkedIn post waiting for your OK. They go out one by one at your posting times, where your network can see them.",
8688
- "zh-Hant": () => "\u6279\u51C6\u6240\u6709\u7B49\u4F60\u78BA\u8A8D\u7684 LinkedIn \u8CBC\u6587\u3002\u5B83\u5011\u6703\u5728\u63A5\u4E0B\u4F86\u7684\u767C\u6587\u6642\u9593\u9010\u7BC7\u767C\u51FA\uFF0C\u4F60\u7684\u4EBA\u8108\u90FD\u770B\u5F97\u5230\u3002"
8869
+ en: (a) => `Approve every ${a.channel ? `${CHANNEL_EN[String(a.channel)] ?? a.channel} ` : ""}post waiting for your OK${a.channel ? "" : ", on every channel"}, including any held for a picture check. They go out one by one at each channel's posting times, where people can see them.`,
8870
+ "zh-Hant": (a) => `\u6279\u51C6\u6240\u6709\u7B49\u4F60\u78BA\u8A8D\u7684${a.channel ? ` ${CHANNEL_ZH[String(a.channel)] ?? a.channel} ` : "\uFF08\u6240\u6709\u6E20\u9053\uFF09"}\u8CBC\u6587\uFF0C\u5305\u62EC\u56E0\u5716\u7247\u6AA2\u67E5\u800C\u66AB\u505C\u7684\u3002\u5B83\u5011\u6703\u5728\u5404\u6E20\u9053\u63A5\u4E0B\u4F86\u7684\u767C\u6587\u6642\u9593\u9010\u7BC7\u767C\u51FA\uFF0C\u5927\u5BB6\u90FD\u770B\u5F97\u5230\u3002`
8689
8871
  },
8690
8872
  series_create: {
8691
8873
  en: (a) => `Plan and write a LinkedIn series about ${q(a.brief)}: ${a.count || 3} posts that read as a series. Each waits in Review for your OK. Uses one AI generation a post.`,
@@ -8754,6 +8936,18 @@ var confirmCopy = {
8754
8936
  en: (a) => `Disconnect ${q(a.app)} from your account. It stops working now and has to be connected again.`,
8755
8937
  "zh-Hant": (a) => `\u5F9E\u4F60\u7684\u5E33\u865F\u65B7\u958B ${q(a.app)}\uFF0C\u5B83\u6703\u7ACB\u5373\u505C\u7528\uFF0C\u4E4B\u5F8C\u8981\u91CD\u65B0\u9023\u63A5\u3002`
8756
8938
  },
8939
+ standing_permission_set: {
8940
+ en: (a) => `Let the key ${q(a.key_name)} run ${Object.entries(a.tools ?? {}).map(([tool2, cap]) => `${tool2}${cap ? ` (up to ${cap} a day)` : ""}`).join(
8941
+ ", "
8942
+ )} on ${a.website} without asking you first${a.days ? `, for ${a.days} days` : ", until you revoke it"}. Every run is logged.`,
8943
+ "zh-Hant": (a) => `\u8B93\u91D1\u9470 ${q(a.key_name)} \u5728 ${a.website} \u4E0A\u4E0D\u7D93\u4F60\u78BA\u8A8D\u76F4\u63A5\u57F7\u884C\uFF1A${Object.entries(
8944
+ a.tools ?? {}
8945
+ ).map(([tool2, cap]) => `${tool2}${cap ? `\uFF08\u6BCF\u5929\u6700\u591A ${cap}\uFF09` : ""}`).join("\u3001")}${a.days ? `\uFF0C\u70BA\u671F ${a.days} \u5929` : "\uFF0C\u76F4\u5230\u4F60\u64A4\u92B7"}\u3002\u6BCF\u6B21\u57F7\u884C\u90FD\u6709\u7D00\u9304\u3002`
8946
+ },
8947
+ standing_permission_revoke: {
8948
+ en: (a) => `End the standing permission of the key ${q(a.key_name)}. Its writes go back to asking you first.`,
8949
+ "zh-Hant": (a) => `\u7D50\u675F\u91D1\u9470 ${q(a.key_name)} \u7684\u9577\u671F\u8A31\u53EF\uFF0C\u4E4B\u5F8C\u5B83\u7684\u6BCF\u500B\u6539\u52D5\u90FD\u8981\u5148\u7D93\u4F60\u78BA\u8A8D\u3002`
8950
+ },
8757
8951
  profile_update: {
8758
8952
  en: (a) => `Change your name to ${q(a.display_name)}.`,
8759
8953
  "zh-Hant": (a) => `\u628A\u4F60\u7684\u540D\u5B57\u6539\u6210 ${q(a.display_name)}\u3002`
@@ -8922,7 +9116,12 @@ var accountTools = [
8922
9116
  group: "workspace",
8923
9117
  need: { resource: "overview", level: "read" },
8924
9118
  risk: "R0",
8925
- run: async (ctx) => (await ws(ctx).inbox()).items.map((i) => ({ type: i.type, id: i.id, title: i.title, body: clip(i.body, 200) }))
9119
+ run: async (ctx) => (await ws(ctx).inbox()).items.map(
9120
+ (i) => (
9121
+ // A report's title and summary may come from an agent: data, never instructions.
9122
+ i.type === "report" ? { type: i.type, id: i.id, ...untrusted({ title: i.title, body: clip(i.body, 200) }) } : { type: i.type, id: i.id, title: i.title, body: clip(i.body, 200) }
9123
+ )
9124
+ )
8926
9125
  }),
8927
9126
  tool({
8928
9127
  name: "setup_status_get",
@@ -9880,6 +10079,85 @@ var brandTools = [
9880
10079
  return { found: out.found, suggestions: out.items.map(({ id, name, website }) => ({ id, name, website })) };
9881
10080
  }
9882
10081
  }),
10082
+ tool({
10083
+ name: "sitemap_list",
10084
+ description: "The sitemaps saved in the business profile: the website's own, competitors' and other sites' (industry sites, partners), each with what it is used for (post_ideas = blog post ideas, channel_ideas = social post ideas, internal_links and post_matching = own sitemaps only) and its last check (pages found or the error). Max 20.",
10085
+ parameters: none,
10086
+ group: "brand",
10087
+ need: { resource: "overview", level: "read" },
10088
+ risk: "R0",
10089
+ run: async (ctx) => {
10090
+ const list2 = (await ws(ctx).brand()).sitemaps ?? [];
10091
+ return { max: SITEMAPS_MAX, sitemaps: list2.map(sitemapResult) };
10092
+ }
10093
+ }),
10094
+ tool({
10095
+ name: "sitemap_upsert",
10096
+ description: "Add a sitemap to the business profile, or change one (found by id from sitemap_list, else by url). owner: own (the website's own; its host must be the website or a subdomain, otherwise it is saved as other), competitor, or other. uses: own may have post_ideas, channel_ideas, internal_links, post_matching; competitor/other only post_ideas, channel_ideas; leave out for all allowed. A new sitemap needs url and owner. Only sitemap addresses the user gives or confirms (never guess one). Saving checks the sitemap; an unreachable one is still saved with the error. Max 20.",
10097
+ parameters: {
10098
+ type: "object",
10099
+ properties: {
10100
+ id: { type: "string", description: "The sitemap to change (from sitemap_list)" },
10101
+ url: { type: "string", description: "The sitemap's http(s) address; with id, its new address" },
10102
+ owner: { type: "string", enum: ["own", "competitor", "other"] },
10103
+ label: { type: "string", description: "A short name, up to 80 characters" },
10104
+ uses: {
10105
+ type: "array",
10106
+ items: { type: "string", enum: ["post_ideas", "channel_ideas", "internal_links", "post_matching"] }
10107
+ }
10108
+ }
10109
+ },
10110
+ group: "brand",
10111
+ need: write2,
10112
+ risk: "W1",
10113
+ run: async (ctx, args) => {
10114
+ const list2 = (await ws(ctx).brand()).sitemaps ?? [];
10115
+ const i = findSitemap(list2, args.id, args.id ? void 0 : args.url);
10116
+ if (args.id && i < 0) throw new Error(`No sitemap with id ${args.id}. Use sitemap_list.`);
10117
+ const old = i >= 0 ? list2[i] : void 0;
10118
+ if (!old && (!args.url || !args.owner)) throw new Error("A new sitemap needs url and owner.");
10119
+ const owner = args.owner ?? old?.owner ?? "other";
10120
+ const entry = cleanSitemap(
10121
+ {
10122
+ ...old,
10123
+ url: args.url ?? old?.url ?? "",
10124
+ owner,
10125
+ label: args.label ?? old?.label ?? null,
10126
+ // A new owner without uses gets that owner's defaults.
10127
+ uses: args.uses ?? (args.owner && args.owner !== old?.owner ? [] : old?.uses ?? [])
10128
+ },
10129
+ ctx.domain ?? void 0
10130
+ );
10131
+ if (typeof entry === "string") throw new Error(entry);
10132
+ if (old && sitemapUrl(old.url) !== entry.url) delete entry.check;
10133
+ const next = old ? list2.map((x, n) => n === i ? entry : x) : [...list2, entry];
10134
+ const problem = checkSitemaps(next);
10135
+ if (problem) throw new Error(problem);
10136
+ const saved = (await ws(ctx).updateBrand({ sitemaps: next })).sitemaps ?? next;
10137
+ const mine = saved[findSitemap(saved, void 0, entry.url)];
10138
+ return {
10139
+ saved: mine ? sitemapResult(mine) : sitemapResult(entry),
10140
+ ...(mine?.owner ?? entry.owner) !== owner ? { note: "Not on this website, so it was saved as another site's sitemap (owner other)." } : {},
10141
+ sitemaps: saved.length
10142
+ };
10143
+ }
10144
+ }),
10145
+ tool({
10146
+ name: "sitemap_remove",
10147
+ description: "Remove a sitemap from the business profile (by id from sitemap_list, or by url). Maggie stops reading it; ideas already found stay.",
10148
+ parameters: { type: "object", properties: { id: { type: "string" }, url: { type: "string" } } },
10149
+ group: "brand",
10150
+ need: write2,
10151
+ risk: "W1",
10152
+ run: async (ctx, { id, url }) => {
10153
+ if (!id && !url) throw new Error("Say which sitemap: id or url.");
10154
+ const list2 = (await ws(ctx).brand()).sitemaps ?? [];
10155
+ const i = findSitemap(list2, id, url);
10156
+ if (i < 0) throw new Error(`No sitemap ${id ?? url} was found. Use sitemap_list.`);
10157
+ const saved = (await ws(ctx).updateBrand({ sitemaps: list2.filter((_, n) => n !== i) })).sitemaps ?? [];
10158
+ return { removed: list2[i]?.url, sitemaps: saved.map((s) => s.url) };
10159
+ }
10160
+ }),
9883
10161
  tool({
9884
10162
  name: "brand_research_update",
9885
10163
  description: "Change the profile's market notes: market_research (text), additional_information (anything else Maggie should know) or the SWOT list (each {name: Strengths|Weaknesses|Opportunities|Threats, description}). A list replaces the whole list.",
@@ -10015,6 +10293,22 @@ function brandGaps(b) {
10015
10293
  check("industries", b.industries, "the industry, for examples and wording");
10016
10294
  return { complete: gaps.length === 0, gaps };
10017
10295
  }
10296
+ function findSitemap(list2, id, url) {
10297
+ if (id) return list2.findIndex((s) => s.id === id);
10298
+ const u = url ? (sitemapUrl(url) ?? url).toLowerCase() : "";
10299
+ return u ? list2.findIndex((s) => (sitemapUrl(s.url) ?? s.url).toLowerCase() === u) : -1;
10300
+ }
10301
+ function sitemapResult(s) {
10302
+ return {
10303
+ id: s.id ?? null,
10304
+ url: s.url,
10305
+ owner: s.owner,
10306
+ label: s.label ?? null,
10307
+ uses: s.uses,
10308
+ check: s.check?.status ?? "unchecked",
10309
+ status: sitemapCheckLine(s.check)
10310
+ };
10311
+ }
10018
10312
 
10019
10313
  // ../../packages/tools/src/feedback.ts
10020
10314
  var account = { resource: "account", level: "read" };
@@ -10167,6 +10461,11 @@ var insightTools = [
10167
10461
 
10168
10462
  // ../../packages/tools/src/me.ts
10169
10463
  var account2 = { resource: "account", level: "read" };
10464
+ var STANDING_TOOLS = ["idea_add", "ideas_add_list", "idea_priority", "rewrite_request"];
10465
+ function standingUnits(name, args) {
10466
+ if (name === "ideas_add_list") return Math.max(1, Array.isArray(args.ideas) ? args.ideas.length : 1);
10467
+ return 1;
10468
+ }
10170
10469
  var meTools = [
10171
10470
  tool({
10172
10471
  name: "api_keys_list",
@@ -10209,6 +10508,54 @@ var meTools = [
10209
10508
  risk: "W3",
10210
10509
  run: async ({ api: api2 }, { grant_id }) => api2.account.revokeApp(String(grant_id))
10211
10510
  }),
10511
+ tool({
10512
+ name: "standing_permissions_list",
10513
+ description: "Standing permissions on the user's API keys: which key (a scheduled agent's) may run which writes on which website without a preview, each tool's daily cap and how much is used today.",
10514
+ parameters: none,
10515
+ group: "account",
10516
+ need: account2,
10517
+ risk: "R0",
10518
+ run: async ({ api: api2 }) => api2.account.standing()
10519
+ }),
10520
+ tool({
10521
+ name: "standing_permission_set",
10522
+ description: `Let one of the user's own API keys (from api_keys_list; a scheduled agent's, not the one in use) run some writes on one website without a preview, each up to a daily cap: ${STANDING_TOOLS.join(", ")}. Replaces any earlier one for that key and website. Leave a cap empty for the default (ideas 10 a day, priority changes 25, rewrites 2). Every run is in the action log; standing_permission_revoke ends it.`,
10523
+ parameters: {
10524
+ type: "object",
10525
+ properties: {
10526
+ key_id: { type: "integer" },
10527
+ key_name: { type: "string", description: "The key's name, for the card" },
10528
+ website: { type: "string", description: "The website (domain)" },
10529
+ tools: {
10530
+ type: "object",
10531
+ description: "Daily cap per tool; include only the tools to allow (0 or empty for the default).",
10532
+ properties: Object.fromEntries(STANDING_TOOLS.map((name) => [name, { type: "integer" }]))
10533
+ },
10534
+ days: { type: "integer", description: "How long it lasts (1-366); leave out for until revoked" }
10535
+ },
10536
+ required: ["key_id", "key_name", "website", "tools"]
10537
+ },
10538
+ group: "account",
10539
+ need: account2,
10540
+ risk: "W3",
10541
+ run: async ({ api: api2 }, { key_id, website, tools: tools2, days }) => api2.account.grantStanding({ key_id: Number(key_id), website: String(website), tools: tools2, days: days ?? null })
10542
+ }),
10543
+ tool({
10544
+ name: "standing_permission_revoke",
10545
+ description: "End a standing permission (from standing_permissions_list): its key goes back to previews for every write.",
10546
+ parameters: {
10547
+ type: "object",
10548
+ properties: {
10549
+ id: { type: "integer" },
10550
+ key_name: { type: "string", description: "The key's name, for the card" }
10551
+ },
10552
+ required: ["id", "key_name"]
10553
+ },
10554
+ group: "account",
10555
+ need: account2,
10556
+ risk: "W2",
10557
+ run: async ({ api: api2 }, { id }) => api2.account.revokeStanding(Number(id))
10558
+ }),
10212
10559
  tool({
10213
10560
  name: "invoices_list",
10214
10561
  description: "The user's latest invoices across the subscriptions they pay for: date, amount, status and a link to view or download each.",
@@ -10349,6 +10696,212 @@ var memoryTools = [
10349
10696
  ];
10350
10697
  var MEMORY_TOOLS = new Set(memoryTools.map((t) => t.name));
10351
10698
 
10699
+ // ../../packages/tools/src/ops.ts
10700
+ var overview = { resource: "overview", level: "read" };
10701
+ var autopilotWrite = { resource: "autopilot", level: "write" };
10702
+ var reportWrite = { resource: "report", level: "write" };
10703
+ var OPS_FEATURES = [
10704
+ "top_up",
10705
+ "precheck",
10706
+ "retry",
10707
+ "weekly_report",
10708
+ "gsc_ideas",
10709
+ "ai_visibility",
10710
+ "idea_cleanup",
10711
+ "competitor_watch",
10712
+ "insight_memory",
10713
+ "post_health",
10714
+ "gbp_reviews"
10715
+ ];
10716
+ var opsTools = [
10717
+ tool({
10718
+ name: "ops_autopilot_get",
10719
+ description: "Maggie's daily operations on the website (Ops Autopilot): whether it is available, and each feature with on/off, when it runs and how its last run went. Features: top_up (fills each social channel's empty slots for the week every morning), precheck (checks each social post before it goes out: no links or phone numbers on Google, a picture on Instagram, no placeholders or promised rankings; holds it as a draft if not), retry (tries a failed post once more), weekly_report (Monday report in the inbox), gsc_ideas (Search Console queries near page one become blog ideas), ai_visibility (weekly AI answer check), idea_cleanup (archives stale low-priority ideas weekly, restorable), competitor_watch (competitors' new articles become ideas), insight_memory (what performed best is remembered), post_health (checks new posts once they're live), gbp_reviews (new Google reviews with reply drafts).",
10720
+ parameters: none,
10721
+ group: "workspace",
10722
+ need: overview,
10723
+ risk: "R0",
10724
+ run: async (ctx) => ws(ctx).opsAutopilot()
10725
+ }),
10726
+ tool({
10727
+ name: "ops_autopilot_set",
10728
+ description: "Switch Ops Autopilot features on or off for this website, and whether the Monday report is also emailed to the website's owner. Only features from ops_autopilot_get; a feature that isn't available yet can be switched but won't run.",
10729
+ parameters: {
10730
+ type: "object",
10731
+ properties: {
10732
+ features: {
10733
+ type: "object",
10734
+ description: `Feature \u2192 true (on) or false (off). Features: ${OPS_FEATURES.join(", ")}`,
10735
+ properties: Object.fromEntries(OPS_FEATURES.map((f) => [f, { type: "boolean" }]))
10736
+ },
10737
+ weekly_email: { type: "boolean", description: "Also email the Monday report to the website's owner" }
10738
+ }
10739
+ },
10740
+ group: "workspace",
10741
+ need: autopilotWrite,
10742
+ risk: "W2",
10743
+ idempotent: true,
10744
+ run: async (ctx, { features, weekly_email }) => {
10745
+ const known = Object.fromEntries(
10746
+ Object.entries(features ?? {}).filter(([k]) => OPS_FEATURES.includes(k)).map(([k, v]) => [k, Boolean(v)])
10747
+ );
10748
+ return ws(ctx).setOpsAutopilot({
10749
+ ...Object.keys(known).length ? { features: known } : {},
10750
+ ...weekly_email === void 0 ? {} : { weekly_email: Boolean(weekly_email) }
10751
+ });
10752
+ }
10753
+ }),
10754
+ tool({
10755
+ name: "reports_list",
10756
+ description: "The website's report inbox, newest first: Maggie's own reports (weekly, daily operations, post health, Google reviews) and reports agents saved. Title and summary each; read one with report_get.",
10757
+ parameters: {
10758
+ type: "object",
10759
+ properties: {
10760
+ kind: {
10761
+ type: "string",
10762
+ enum: ["weekly", "daily_ops", "agent", "post_health", "reviews", "ai_visibility", "search"]
10763
+ },
10764
+ unread: { type: "boolean", description: "Only reports nobody has opened yet" },
10765
+ limit: { type: "integer", minimum: 1, maximum: 50 }
10766
+ }
10767
+ },
10768
+ group: "reports",
10769
+ need: overview,
10770
+ risk: "R0",
10771
+ run: async (ctx, { kind, unread, limit }) => {
10772
+ const r = await ws(ctx).reports({ kind, unread, limit: int(limit, 1, 50, 20) });
10773
+ return {
10774
+ unread: r.unread,
10775
+ items: r.items.map(({ title, summary, ...rest }) => ({ ...rest, ...untrusted({ title, summary }) }))
10776
+ };
10777
+ }
10778
+ }),
10779
+ tool({
10780
+ name: "report_get",
10781
+ description: "One report in full (its text in Markdown and any data); marks it read.",
10782
+ parameters: { type: "object", properties: { id: { type: "integer" } }, required: ["id"] },
10783
+ group: "reports",
10784
+ need: overview,
10785
+ risk: "R0",
10786
+ run: async (ctx, { id }) => {
10787
+ const r = await ws(ctx).inboxReport(int(id, 1, Number.MAX_SAFE_INTEGER, 0));
10788
+ const { title, summary, body_md, data, ...rest } = r;
10789
+ return { ...rest, ...untrusted({ title, summary, body_md, data }) };
10790
+ }
10791
+ }),
10792
+ tool({
10793
+ name: "report_save",
10794
+ description: "Save a report to the website's inbox (for an agent's scheduled run: what it found and did). The owner gets a push and sees it in the app's Inbox. Up to 20 agent reports a day per website, 50,000 characters each. Saving changes nothing else.",
10795
+ parameters: {
10796
+ type: "object",
10797
+ properties: {
10798
+ title: { type: "string", description: "Short, e.g. 'Daily analysis, 22 Oct'" },
10799
+ summary: { type: "string", description: "One or two sentences (up to 500 characters) shown in the Inbox" },
10800
+ body_md: { type: "string", description: "The full report in Markdown" }
10801
+ },
10802
+ required: ["title"]
10803
+ },
10804
+ group: "reports",
10805
+ need: reportWrite,
10806
+ risk: "W1",
10807
+ run: async (ctx, { title, summary, body_md }) => ws(ctx).saveReport({
10808
+ title: String(title ?? "").slice(0, 200),
10809
+ ...summary ? { summary: String(summary).slice(0, 500) } : {},
10810
+ ...body_md ? { body_md: String(body_md).slice(0, 5e4) } : {}
10811
+ })
10812
+ }),
10813
+ tool({
10814
+ name: "idea_cleanup_list",
10815
+ description: "Ideas Ops Autopilot archived in its weekly clean-up (stale, low-priority ideas Maggie found herself; never ones a person added), newest first, each with why. Bring one back with idea_restore_cleanup.",
10816
+ parameters: { type: "object", properties: { limit: { type: "integer", minimum: 1, maximum: 200 } } },
10817
+ group: "blog",
10818
+ need: { resource: "autopilot", level: "read" },
10819
+ risk: "R0",
10820
+ run: async (ctx, { limit }) => {
10821
+ const r = await ws(ctx).ideaCleanup(int(limit, 1, 200, 50));
10822
+ return { items: r.items.map(({ reason, ...rest }) => ({ ...rest, ...untrusted({ reason }) })) };
10823
+ }
10824
+ }),
10825
+ tool({
10826
+ name: "idea_restore_cleanup",
10827
+ description: "Bring an idea the weekly clean-up archived back to the waiting list (id from idea_cleanup_list).",
10828
+ parameters: { type: "object", properties: { id: { type: "integer" } }, required: ["id"] },
10829
+ group: "blog",
10830
+ need: autopilotWrite,
10831
+ risk: "W1",
10832
+ idempotent: true,
10833
+ run: async (ctx, { id }) => ws(ctx).restoreIdeaCleanup(int(id, 1, Number.MAX_SAFE_INTEGER, 0))
10834
+ }),
10835
+ tool({
10836
+ name: "post_health_get",
10837
+ description: "Checks of the blog posts that went live in the last week (Ops Autopilot, daily): whether each page opens, and has a title, a meta description, a canonical link to itself, an og:image and working links on the site. Each with its issues in plain words.",
10838
+ parameters: { type: "object", properties: { limit: { type: "integer", minimum: 1, maximum: 100 } } },
10839
+ group: "reports",
10840
+ need: { resource: "report", level: "read" },
10841
+ risk: "R0",
10842
+ run: async (ctx, { limit }) => ws(ctx).postHealth(int(limit, 1, 100, 30))
10843
+ }),
10844
+ tool({
10845
+ name: "gbp_reviews_list",
10846
+ description: "Reviews of the website's Google Business profile (read every 6 hours by Ops Autopilot), newest first, each with Maggie's reply draft or the reply already sent. status: none (no reply yet), drafted (a draft waits to be sent), sent.",
10847
+ parameters: {
10848
+ type: "object",
10849
+ properties: {
10850
+ status: { type: "string", enum: ["none", "drafted", "sent", "failed"] },
10851
+ limit: { type: "integer", minimum: 1, maximum: 100 }
10852
+ }
10853
+ },
10854
+ group: "social",
10855
+ need: { resource: "autopilot", level: "read" },
10856
+ risk: "R0",
10857
+ run: async (ctx, { status, limit }) => {
10858
+ const r = await ws(ctx).googleReviews({ status, limit: int(limit, 1, 100, 30) });
10859
+ return {
10860
+ connected: r.connected,
10861
+ waiting: r.waiting,
10862
+ items: r.items.map(({ reviewer, comment, ...rest }) => ({ ...rest, ...untrusted({ reviewer, comment }) }))
10863
+ };
10864
+ }
10865
+ }),
10866
+ tool({
10867
+ name: "gbp_review_draft",
10868
+ description: "Ask Maggie for a new reply draft to one Google review (one AI generation). Nothing is posted.",
10869
+ parameters: { type: "object", properties: { id: { type: "integer" } }, required: ["id"] },
10870
+ group: "social",
10871
+ need: autopilotWrite,
10872
+ risk: "W2",
10873
+ gates: ["LLM"],
10874
+ run: async (ctx, { id }) => {
10875
+ const r = await ws(ctx).redraftGoogleReview(int(id, 1, Number.MAX_SAFE_INTEGER, 0));
10876
+ const { reviewer, comment, ...rest } = r;
10877
+ return { ...rest, ...untrusted({ reviewer, comment }) };
10878
+ }
10879
+ }),
10880
+ tool({
10881
+ name: "gbp_review_reply",
10882
+ description: "Post the reply to one Google review on the website's Google Business profile: the draft, or your own text (up to 1,000 characters). Public: everyone who sees the review sees the reply. A review can be answered once here.",
10883
+ parameters: {
10884
+ type: "object",
10885
+ properties: {
10886
+ id: { type: "integer" },
10887
+ text: { type: "string", description: "The reply; leave out to send the draft" }
10888
+ },
10889
+ required: ["id"]
10890
+ },
10891
+ group: "social",
10892
+ need: autopilotWrite,
10893
+ risk: "W3",
10894
+ run: async (ctx, { id, text: text3 }) => {
10895
+ const r = await ws(ctx).replyGoogleReview(
10896
+ int(id, 1, Number.MAX_SAFE_INTEGER, 0),
10897
+ text3 === void 0 ? void 0 : String(text3).slice(0, 1e3)
10898
+ );
10899
+ const { reviewer, comment, ...rest } = r;
10900
+ return { ...rest, ...untrusted({ reviewer, comment }) };
10901
+ }
10902
+ })
10903
+ ];
10904
+
10352
10905
  // ../../packages/tools/src/publishing.ts
10353
10906
  var read3 = { resource: "autopilot", level: "read" };
10354
10907
  var write4 = { resource: "autopilot", level: "write" };
@@ -11386,12 +11939,18 @@ var socialTools = [
11386
11939
  }),
11387
11940
  tool({
11388
11941
  name: "social_sources_list",
11389
- description: "Published articles a social post can be made from (optionally matching a search).",
11942
+ description: "Published articles a social post can be made from (optionally matching a search), newest first, up to 30: each with when it was published and used_on, the channels it was shared to in the last 30 days with the latest date (empty = not shared lately; an article is free to share again 30 days after its last post).",
11390
11943
  parameters: { type: "object", properties: { search: { type: "string" } } },
11391
11944
  group: "social",
11392
11945
  need: read5,
11393
11946
  risk: "R0",
11394
- run: async (ctx, { search }) => (await ws(ctx).socialSources(search ?? "")).items.slice(0, 15).map((s) => ({ id: s.id, ...untrusted({ title: s.title }), url: s.url }))
11947
+ run: async (ctx, { search }) => (await ws(ctx).socialSources(search ?? "")).items.slice(0, 30).map((s) => ({
11948
+ id: s.id,
11949
+ ...untrusted({ title: s.title }),
11950
+ url: s.url,
11951
+ published_at: s.published_at ?? null,
11952
+ used_on: s.used_on ?? {}
11953
+ }))
11395
11954
  }),
11396
11955
  tool({
11397
11956
  name: "social_from_article",
@@ -12367,6 +12926,7 @@ var tools = [
12367
12926
  ...accountTools,
12368
12927
  ...meTools,
12369
12928
  ...reportTools,
12929
+ ...opsTools,
12370
12930
  ...insightTools,
12371
12931
  ...brandTools,
12372
12932
  ...blogTools,
@@ -22354,6 +22914,7 @@ var CONTROL_ARGS = ["workspace", "confirm", "confirmation"];
22354
22914
  var NOT_ON_MCP = /* @__PURE__ */ new Set(["billing_link"]);
22355
22915
  var DESTRUCTIVE = /_(remove|delete|discard|revoke)$/;
22356
22916
  var isAccountTool = (t) => t.need.resource === "account";
22917
+ var isStanding = (name) => STANDING_TOOLS.includes(name);
22357
22918
  function inputSchema(t) {
22358
22919
  const props = { ...t.parameters.properties ?? {} };
22359
22920
  props.workspace = WORKSPACE;
@@ -22422,7 +22983,9 @@ var OPEN_WORLD = /* @__PURE__ */ new Set([
22422
22983
  "seo_audit_run",
22423
22984
  "ai_visibility_prompt_check",
22424
22985
  "idea_add",
22425
- "competitor_suggestions_find"
22986
+ "competitor_suggestions_find",
22987
+ // Saving makes CMO.so fetch the sitemap from its website.
22988
+ "sitemap_upsert"
22426
22989
  ]);
22427
22990
  var OVERWRITES = /* @__PURE__ */ new Set([
22428
22991
  "ai_visibility_keywords_set",
@@ -22493,9 +23056,10 @@ function createMcpServer(deps) {
22493
23056
  { capabilities: { tools: {}, resources: {}, prompts: {} }, instructions: INSTRUCTIONS }
22494
23057
  );
22495
23058
  server.setRequestHandler(ListToolsRequestSchema, async () => {
22496
- const keyAccess = (await me().catch(() => null))?.key?.access ?? null;
23059
+ const key = (await me().catch(() => null))?.key;
23060
+ const keyAccess = key?.access ?? null;
22497
23061
  return {
22498
- tools: offered().filter((t) => !(isWrite(t) && keyAccess === "read")).map((t) => ({
23062
+ tools: offered().filter((t) => !(isWrite(t) && keyAccess === "read")).filter((t) => !(isWrite(t) && key?.standing && !isStanding(t.name))).map((t) => ({
22499
23063
  name: t.name,
22500
23064
  title: t.name.replace(/_/g, " "),
22501
23065
  description: description(t),
@@ -22511,6 +23075,11 @@ function createMcpServer(deps) {
22511
23075
  const t = toolByName.get(name);
22512
23076
  if (!t || excluded(name)) return fail(`There is no tool called ${name}.`);
22513
23077
  try {
23078
+ const agentKey = isWrite(t) && (await me().catch(() => null))?.key?.standing === true;
23079
+ if (agentKey && (args.confirm === true || !isStanding(t.name)))
23080
+ return fail(
23081
+ "This is an agent's key: it can only make the changes its standing permission covers, and can't confirm anything itself. Leave other changes for the person."
23082
+ );
22514
23083
  let approval = null;
22515
23084
  if (isWrite(t) && args.confirm === true) {
22516
23085
  if (deps.previewOnly)
@@ -22535,6 +23104,23 @@ function createMcpServer(deps) {
22535
23104
  const callArgs = approval ? approval.calls[0]?.args ?? {} : toolArgs(args);
22536
23105
  const ctx = { api: api2, domain, client: "mcp" };
22537
23106
  if (!isWrite(t)) return ok(await t.run(ctx, callArgs), viewForTool(t.name) ? { workspace: domain } : void 0);
23107
+ let standing = null;
23108
+ if (!approval && !deps.previewOnly && domain && isStanding(t.name)) {
23109
+ const claim2 = await api2.account?.claimStanding({ website: domain, tool: t.name, units: standingUnits(t.name, callArgs) }).catch(() => null);
23110
+ if (claim2?.allowed) {
23111
+ standing = claim2.permission_id;
23112
+ approval = {
23113
+ id: randomUUID2(),
23114
+ calls: [{ name: t.name, args: callArgs }],
23115
+ principal,
23116
+ domain,
23117
+ expires: Date.now()
23118
+ };
23119
+ } else if (agentKey) {
23120
+ const why = claim2?.reason === "daily_cap" ? `today's limit for ${t.name} is used up (${claim2.daily_cap})` : claim2?.reason === "tool_not_included" ? `the standing permission doesn't include ${t.name}` : claim2 ? `there is no standing permission for ${domain} (${claim2.reason})` : "the permission check failed; try again later";
23121
+ return fail(`Not done: ${why}.`);
23122
+ }
23123
+ }
22538
23124
  if (!approval) {
22539
23125
  const calls = [{ name: t.name, args: callArgs }];
22540
23126
  const summary2 = t.confirm?.(callArgs, "en") ?? t.description;
@@ -22548,7 +23134,7 @@ function createMcpServer(deps) {
22548
23134
  next: "Show the preview to the user. If they agree, call this tool again with confirm: true and this confirmation (it runs exactly what was previewed). Nothing has changed yet."
22549
23135
  });
22550
23136
  }
22551
- const summary = t.confirm?.(callArgs, "en") ?? t.description;
23137
+ const summary = `${t.confirm?.(callArgs, "en") ?? t.description}${standing ? ` (standing permission #${standing})` : ""}`;
22552
23138
  try {
22553
23139
  const result = await t.run({ api: withIdempotency(api2, approval.id), domain, client: "mcp" }, callArgs);
22554
23140
  const added = t.name === "website_add" ? result?.domain : null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmo-so/mcp",
3
- "version": "0.7.6",
3
+ "version": "0.8.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",