hermoso 0.1.462 → 0.1.463
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/README.md +2 -2
- package/mcp/openai-extensions.mjs +1 -1
- package/mcp/roster-scope.mjs +2 -0
- package/mcp/tools.mjs +401 -76
- package/package.json +2 -2
package/mcp/tools.mjs
CHANGED
|
@@ -1986,6 +1986,12 @@ export const INBOX_SOURCES = [
|
|
|
1986
1986
|
// chat, because a single call spans every chat that wrote in and the reply target is a property of each ROW, not
|
|
1987
1987
|
// of the envelope. Its `reply` is the ordinary publish route: on Telegram a reply IS a message to a chat id.
|
|
1988
1988
|
{ source: 'telegram_dm', provider: 'telegram', read: '/api/telegram/messages', reply: '/api/telegram/post', kind: 'dm', label: 'Telegram message' },
|
|
1989
|
+
// INTERCOM, added 2026-10-27 — the brand's own SUPPORT inbox, which is where its customers talk most and which a list
|
|
1990
|
+
// titled "everything people said to this brand" left out. Reads the OPEN conversations (the existing
|
|
1991
|
+
// list_intercom_conversations route, one row per conversation, so nothing is folded). The reply route carries the
|
|
1992
|
+
// conversation id in its PATH, so `:id` is filled in by reply_to_inbox_item; that route sends to the customer only
|
|
1993
|
+
// with confirm:true, which reply_to_inbox_item passes because its own contract is "the user already said yes".
|
|
1994
|
+
{ source: 'intercom', provider: 'intercom', read: '/api/intercom/conversations', reply: '/api/intercom/conversations/:id/reply', kind: 'support', label: 'Intercom conversation', query: { view: 'open' } },
|
|
1989
1995
|
];
|
|
1990
1996
|
|
|
1991
1997
|
export const INBOX_SOURCE_NAMES = INBOX_SOURCES.map((s) => s.source);
|
|
@@ -2110,8 +2116,11 @@ export function normalizeInboxItem(source, row = {}) {
|
|
|
2110
2116
|
// withholds has no text, and this line used to drop every one of them — an Instagram inbox full of story
|
|
2111
2117
|
// mentions read as "Nothing waiting." The read route's one-line `summary` stands in for the missing text.
|
|
2112
2118
|
const text = pick('text', 'message', 'comment', 'body', 'content', 'reviewReply.comment', 'snippet.textDisplay') || pick('summary');
|
|
2119
|
+
// An Intercom conversation's words are its `preview` (the opening message) under its `subject`. Read only for that
|
|
2120
|
+
// source, so no other vendor's `preview` field is ever mistaken for what someone said.
|
|
2121
|
+
const icText = source === 'intercom' ? [pick('subject'), pick('preview')].filter(Boolean).join(' — ') : '';
|
|
2113
2122
|
const rating = row.starRating ?? row.rating ?? null;
|
|
2114
|
-
if (!nativeId || (!text && rating == null)) return null;
|
|
2123
|
+
if (!nativeId || (!text && !icText && rating == null)) return null;
|
|
2115
2124
|
const mk = src.kind === 'dm' && typeof row.kind === 'string' && typeof row.summary === 'string' ? row.kind : ''; // the Meta read shape only
|
|
2116
2125
|
// A META DM FROM ONE OF SEVERAL PAGES CARRIES ITS PAGE IN THE ID (2026-10-26): the reply has to leave through the
|
|
2117
2126
|
// Page the person wrote to, and reply_to_inbox_item only ever sees the id. `~p` cannot occur in a Meta scoped id.
|
|
@@ -2125,16 +2134,18 @@ export function normalizeInboxItem(source, row = {}) {
|
|
|
2125
2134
|
// `from` and `sender*` are the DM spellings: Meta's conversation route hands back `from` as a display string
|
|
2126
2135
|
// (its `from.name` sibling is the COMMENT shape) and Bluesky's hands back a handle, falling back to the DID
|
|
2127
2136
|
// when the profile is not in the payload. A DID is ugly and it is still the truth about who wrote.
|
|
2128
|
-
author: pick('author', 'from.name', 'username', 'reviewer.displayName', 'authorDisplayName', 'snippet.authorDisplayName', 'from', 'sender', 'senderHandle', 'senderDid') || 'unknown',
|
|
2129
|
-
text,
|
|
2137
|
+
author: pick('author', 'from.name', 'username', 'reviewer.displayName', 'authorDisplayName', 'snippet.authorDisplayName', 'from', 'sender', 'senderHandle', 'senderDid', ...(source === 'intercom' ? ['customer.name', 'customer.email'] : [])) || 'unknown',
|
|
2138
|
+
text: text || icText,
|
|
2130
2139
|
rating: rating == null ? undefined : rating,
|
|
2131
|
-
at: pick('created_time', 'createdAt', 'timestamp', 'createTime', 'created_utc', 'publishedAt', 'snippet.publishedAt', 'at', 'sentAt') || '',
|
|
2140
|
+
at: (source === 'intercom' ? pick('updatedAt', 'createdAt') : pick('created_time', 'createdAt', 'timestamp', 'createTime', 'created_utc', 'publishedAt', 'snippet.publishedAt', 'at', 'sentAt')) || '',
|
|
2132
2141
|
permalink: pick('permalink', 'permalink_url', 'url', 'link') || '',
|
|
2133
2142
|
// A MESSAGE THE BRAND ITSELF SENT NEEDS NO REPLY. `inbound === false` / `fromMe === true` is the DM way of
|
|
2134
2143
|
// saying what `reviewReply.comment` says on a review, and without it `unansweredOnly` would put the brand's
|
|
2135
2144
|
// own outgoing messages on the list of things it still has to answer.
|
|
2136
2145
|
answered: row.answered === true || !!pick('reviewReply.comment') || row.hasReplied === true
|
|
2137
|
-
|| row.inbound === false || row.fromMe === true
|
|
2146
|
+
|| row.inbound === false || row.fromMe === true
|
|
2147
|
+
// Intercom's waiting_since is set while the CUSTOMER waits on the team and cleared once a teammate replies.
|
|
2148
|
+
|| (source === 'intercom' && !row.waitingSince),
|
|
2138
2149
|
canReply: !!src.reply,
|
|
2139
2150
|
...(row.messageId || row.eventId ? { messageId: String(row.messageId || row.eventId) } : {}),
|
|
2140
2151
|
// WHAT THE DM IS (Meta's read routes, 2026-10-25): its kind, the sentence to read out, the media / story /
|
|
@@ -2200,7 +2211,7 @@ export const TOOL_GROUPS = {
|
|
|
2200
2211
|
// list in prose has no check that can hold it true, so the fix is to stop making a claim that can rot: the tools
|
|
2201
2212
|
// themselves are the roster, and enabling the group is what reveals them.
|
|
2202
2213
|
ads: 'Paid campaign management on every connected ad platform — build, budget, target and report on campaigns, ad sets, ads, keywords and audiences. Enable this group to see which platforms this workspace can reach.',
|
|
2203
|
-
files: 'Google Drive, Sheets, Docs, OneDrive and
|
|
2214
|
+
files: 'Google Drive, Sheets, Docs, OneDrive, Notion and Canva.',
|
|
2204
2215
|
workspace:'Profiles, memory, skills, saved creators, connectors and team.',
|
|
2205
2216
|
};
|
|
2206
2217
|
export const TOOL_GROUP_NAMES = ['core', ...Object.keys(TOOL_GROUPS)];
|
|
@@ -4489,7 +4500,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4489
4500
|
description: "EVERYTHING PEOPLE SAID TO THIS BRAND, across every connected channel, in one list: Facebook and "
|
|
4490
4501
|
+ "Instagram comments, Threads replies and mentions, YouTube and Reddit comments, Google Business reviews, "
|
|
4491
4502
|
+ "Bluesky replies and mentions, and X mentions — plus DIRECT MESSAGES on Meta (Messenger and Instagram "
|
|
4492
|
-
+ "Direct), Instagram, Bluesky, X and Telegram. Use this for 'what do I need to reply to', 'any new comments', 'any new DMs', 'how are people responding'. Each "
|
|
4503
|
+
+ "Direct), Instagram, Bluesky, X and Telegram. Support tickets too: the open conversations in the brand's Intercom inbox. Use this for 'what do I need to reply to', 'any new comments', 'any new DMs', 'how are people responding'. Each "
|
|
4493
4504
|
+ "item carries a composite id you hand straight to reply_to_inbox_item. A channel that is not connected is "
|
|
4494
4505
|
+ "skipped silently; a channel that FAILS to read is named in `notes` rather than dropped, so a short list is "
|
|
4495
4506
|
+ "never mistaken for a quiet week. Each DM conversation folds to ONE item (the newest thing the other person said). "
|
|
@@ -4548,7 +4559,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4548
4559
|
if (pageId && (src.provider === 'meta' || src.provider === 'instagram')) q.pageId = String(pageId).trim();
|
|
4549
4560
|
try {
|
|
4550
4561
|
const d = await apiGet(whole ? src.readAll : src.read, q);
|
|
4551
|
-
const rows = Array.isArray(d) ? d : (d.comments || d.replies || d.mentions || d.reviews || d.messages || d.events || d.items || d.data || []);
|
|
4562
|
+
const rows = Array.isArray(d) ? d : (d.comments || d.replies || d.mentions || d.reviews || d.messages || d.events || d.items || d.conversations || d.data || []);
|
|
4552
4563
|
// A SOURCE THAT READ ONLY PART OF ITSELF SAYS SO (2026-10-26, customer report: Instagram DMs at limit >= 32 came
|
|
4553
4564
|
// back as 0 items and NO note, because the DM inbox route reported its failure in `unreadable` + `note` and this
|
|
4554
4565
|
// loop only ever read the rows). Any route answering `unreadable` has its sentence carried into `notes`, on both
|
|
@@ -4586,7 +4597,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4586
4597
|
description: "Answer an inbox item BY ITS COMPOSITE ID — the `id` list_inbox returned, e.g. 'facebook:123_456' "
|
|
4587
4598
|
+ "or 'google_business:accounts/1/locations/2/reviews/3'. Routes to the right channel for you; you do not need "
|
|
4588
4599
|
+ "to know which reply tool a platform uses. The reply is posted as the brand — PUBLIC on every comment, reply, "
|
|
4589
|
-
+ "mention and review, and PRIVATE on the
|
|
4600
|
+
+ "mention and review, and PRIVATE on the DM sources and on Intercom (it goes to the customer from the brand's support inbox) — so show the user the "
|
|
4590
4601
|
+ "exact wording and get their go-ahead first either way. X is the one exception and says so: a reply there is a new post, "
|
|
4591
4602
|
+ "so it goes through post_to_x with replyTo.",
|
|
4592
4603
|
inputSchema: {
|
|
@@ -4622,12 +4633,20 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4622
4633
|
else if (t.source === 'bluesky_dm') payload.convoId = t.nativeId;
|
|
4623
4634
|
else if (t.source === 'x_dm') payload.conversationId = t.nativeId;
|
|
4624
4635
|
else if (t.source === 'telegram_dm') payload.chatId = t.nativeId;
|
|
4625
|
-
|
|
4636
|
+
// Intercom: the id rides in the PATH, and the route sends to the customer only with confirm:true (this tool's own
|
|
4637
|
+
// contract is that the user already approved the wording). Anything but a confirmed read-back is said as such.
|
|
4638
|
+
else if (t.source === 'intercom') payload.confirm = true;
|
|
4639
|
+
const route = t.route.includes(':id') ? t.route.replace(':id', encodeURIComponent(t.nativeId)) : t.route;
|
|
4640
|
+
const r = await apiPost(route, payload);
|
|
4641
|
+
if (t.source === 'intercom' && r?.confirmed !== true) {
|
|
4642
|
+
const n = r?.dryRun ? 'Intercom did not send the reply (it returned a preview instead); nothing reached the customer.' : (r?.note || 'Intercom accepted the reply but did not confirm it; read the conversation before sending again.');
|
|
4643
|
+
return ok(n, { ok: false, source: t.source, note: n });
|
|
4644
|
+
}
|
|
4626
4645
|
// A DM IS PRIVATE. Saying "replied publicly as the brand" about a direct message is wrong in the one
|
|
4627
4646
|
// direction that matters — it invites a caller to be more guarded than it needs to be about a private reply,
|
|
4628
4647
|
// and, worse, teaches that the same sentence covers both.
|
|
4629
|
-
const note = t.source.endsWith('_dm') ? `Sent a private reply to the ${t.label} as the brand.` : `Replied to the ${t.label} as the brand.`;
|
|
4630
|
-
return ok(note, { ok: true, source: t.source, id: r?.id || r?.messageId || r?.name || undefined, note });
|
|
4648
|
+
const note = (t.source.endsWith('_dm') || t.source === 'intercom') ? `Sent a private reply to the ${t.label} as the brand.` : `Replied to the ${t.label} as the brand.`;
|
|
4649
|
+
return ok(note, { ok: true, source: t.source, id: r?.id || r?.messageId || r?.partId || r?.name || undefined, note });
|
|
4631
4650
|
}));
|
|
4632
4651
|
|
|
4633
4652
|
// Back to core. The inbox + Bluesky tools above set the marker to `channels`, and the marker is sticky —
|
|
@@ -6150,10 +6169,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6150
6169
|
// to guess, so a schedule that cannot carry it can only fail at fire time with nobody watching.
|
|
6151
6170
|
boardId: z.string().optional().describe('PINTEREST — REQUIRED whenever pinterest is a channel: the board the Pin goes on, from list_pinterest_boards. The user picks it; a Pin on the wrong board is a public mistake. Scheduling pinterest without one is refused immediately.'),
|
|
6152
6171
|
chatId: z.string().optional().describe('TELEGRAM — REQUIRED whenever telegram is a channel: WHICH chat, group or channel the bot posts to. A public channel’s @username (@hermosoai) or the numeric id (a group is negative; a supergroup or channel starts with -100). There is no default and there cannot be one — the Telegram Bot API publishes no method that lists a bot’s chats — so scheduling telegram without one is refused up front. list_telegram_chats finds ids for chats that have messaged the bot in the last 24 hours.'),
|
|
6153
|
-
buttons: z.array(z.union([z.object({ text: z.string(), url: z.string(), style: z.enum(['danger', 'success', 'primary']).optional() }), z.array(z.object({ text: z.string(), url: z.string(), style: z.enum(['danger', 'success', 'primary']).optional() }))])).optional().describe(
|
|
6154
|
-
parseMode: z.enum(['HTML']).optional().describe("TELEGRAM ONLY — 'HTML'
|
|
6155
|
-
silent: z.boolean().optional().describe('TELEGRAM ONLY —
|
|
6156
|
-
disablePreview: z.boolean().optional().describe('TELEGRAM ONLY —
|
|
6172
|
+
buttons: z.array(z.union([z.object({ text: z.string(), url: z.string(), style: z.enum(['danger', 'success', 'primary']).optional() }), z.array(z.object({ text: z.string(), url: z.string(), style: z.enum(['danger', 'success', 'primary']).optional() }))])).optional().describe('TELEGRAM ONLY — URL buttons under the post, as post_to_telegram takes them; never on an album.'),
|
|
6173
|
+
parseMode: z.enum(['HTML']).optional().describe("TELEGRAM ONLY — 'HTML' for Telegram's HTML tags; omit for plain text."),
|
|
6174
|
+
silent: z.boolean().optional().describe('TELEGRAM ONLY — no notification sound.'),
|
|
6175
|
+
disablePreview: z.boolean().optional().describe('TELEGRAM ONLY — no link preview.'),
|
|
6157
6176
|
linkedinOrganizationId: z.string().optional().describe('LINKEDIN — publish as a COMPANY PAGE instead of the connected personal profile. The organization id from list_linkedin_pages. Omit and it posts as the person: an unset id means the profile, never “probably the company”. A Page can also carry VIDEO, which a personal profile cannot.'),
|
|
6158
6177
|
pageId: z.string().optional().describe('FACEBOOK / INSTAGRAM / THREADS — which connected Facebook Page (and its linked Instagram) publishes, from list_meta_pages. Needed when the profile has more than one Page connected; with several and no id the post is refused at fire time rather than sent from the wrong brand.'),
|
|
6159
6178
|
locationId: z.string().optional().describe("GOOGLE BUSINESS PROFILE — which listing, e.g. 'locations/123' from list_business_locations. Needed when the account manages more than one storefront; it is never chosen for the user."),
|
|
@@ -6330,10 +6349,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6330
6349
|
aiGenerated: z.boolean().optional().describe('AI-CONTENT DISCLOSURE (Instagram / Facebook Reel is_ai_generated, TikTok is_aigc, YouTube containsSyntheticMedia, Pinterest ai_disclosures). OMIT IT and the value already on the post stays; a post that never had one is decided from provenance at publish: a Hermoso render is declared AI-generated, media that came through upload_file or from an external URL (the user\u2019s own photos or footage) is NOT. Pass true or false only to override.'),
|
|
6331
6350
|
boardId: z.string().optional().describe('PINTEREST — move the Pin to a different board (list_pinterest_boards)'),
|
|
6332
6351
|
chatId: z.string().optional().describe('TELEGRAM — send it to a different chat, group or channel (@username or numeric id). It can be changed but never cleared: telegram cannot publish without one.'),
|
|
6333
|
-
buttons: z.array(z.union([z.object({ text: z.string(), url: z.string(), style: z.enum(['danger', 'success', 'primary']).optional() }), z.array(z.object({ text: z.string(), url: z.string(), style: z.enum(['danger', 'success', 'primary']).optional() }))])).optional().describe('TELEGRAM ONLY — replace the URL buttons
|
|
6334
|
-
parseMode: z.enum(['HTML']).optional().describe("TELEGRAM ONLY — 'HTML'
|
|
6335
|
-
silent: z.boolean().optional().describe('TELEGRAM ONLY —
|
|
6336
|
-
disablePreview: z.boolean().optional().describe('TELEGRAM ONLY —
|
|
6352
|
+
buttons: z.array(z.union([z.object({ text: z.string(), url: z.string(), style: z.enum(['danger', 'success', 'primary']).optional() }), z.array(z.object({ text: z.string(), url: z.string(), style: z.enum(['danger', 'success', 'primary']).optional() }))])).optional().describe('TELEGRAM ONLY — replace the URL buttons ([] removes them).'),
|
|
6353
|
+
parseMode: z.enum(['HTML']).optional().describe("TELEGRAM ONLY — 'HTML' for Telegram's HTML tags."),
|
|
6354
|
+
silent: z.boolean().optional().describe('TELEGRAM ONLY — no notification sound.'),
|
|
6355
|
+
disablePreview: z.boolean().optional().describe('TELEGRAM ONLY — no link preview.'),
|
|
6337
6356
|
linkedinOrganizationId: z.string().optional().describe('LINKEDIN — target a different company Page, or "" to post as the connected person instead'),
|
|
6338
6357
|
pageId: z.string().optional().describe('FACEBOOK / INSTAGRAM / THREADS — publish from a different connected Page (list_meta_pages)'),
|
|
6339
6358
|
locationId: z.string().optional().describe('GOOGLE BUSINESS — a different listing (list_business_locations)'),
|
|
@@ -12047,7 +12066,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12047
12066
|
interval: z.number().optional().describe('-300000 realtime, -3600000 hourly, 1 daily, 7 weekly, 30 monthly'),
|
|
12048
12067
|
groupBy: z.string().optional(),
|
|
12049
12068
|
groupBy2: z.string().optional().describe("a SECOND group-by (Amplitude's g2), e.g. groupBy gp:utm_campaign + groupBy2 country"),
|
|
12050
|
-
segments: z.array(z.object({ prop: z.string().optional(), op: z.string().optional(), values: z.array(z.string()).optional(), type: z.string().optional(), event_type: z.string().optional(), value: z.number().optional(), time_type: z.string().optional(), time_value: z.number().optional() }).passthrough()).optional().describe("FILTER to only these users (Amplitude's segment `s`): [{prop:'gp:utm_campaign', op:'is', values:['summer']}]. op is one of is, is not, contains, does not contain, less, less or equal, greater, greater or equal, set is, set is not. Custom user properties are gp:<name>; built-ins are country, city, region, platform, os, device_type, version, language, paying; prop userdata_cohort + a cohort id filters to a behavioural cohort. A \"who performed\" condition is {type:'event', event_type, op:'>=', value, time_type:'
|
|
12069
|
+
segments: z.array(z.object({ prop: z.string().optional(), op: z.string().optional(), values: z.array(z.string()).optional(), type: z.string().optional(), event_type: z.string().optional(), value: z.number().optional(), time_type: z.string().optional(), time_value: z.number().optional() }).passthrough()).optional().describe("FILTER to only these users (Amplitude's segment `s`): [{prop:'gp:utm_campaign', op:'is', values:['summer']}]. op is one of is, is not, contains, does not contain, less, less or equal, greater, greater or equal, set is, set is not. Custom user properties are gp:<name>; built-ins are country, city, region, platform, os, device_type, version, language, paying; prop userdata_cohort + a cohort id filters to a behavioural cohort. A \"who performed\" condition is {type:'event', event_type, op:'>=', value:1, time_type:'rolling', time_value:30} (did it at least once in the last 30 days); symbol operators only."),
|
|
12051
12070
|
limit: z.number().optional().describe('≤1000'),
|
|
12052
12071
|
},
|
|
12053
12072
|
outputSchema: { series: z.array(z.any()).optional(), seriesLabels: z.array(z.any()).optional(), xValues: z.array(z.any()).optional(), raw: z.any().optional(), note: z.string().optional() },
|
|
@@ -12067,7 +12086,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12067
12086
|
end: z.string().optional().describe('YYYYMMDD or YYYY-MM-DD (default today)'),
|
|
12068
12087
|
groupBy: z.string().optional().describe('at most ONE — a second is refused'),
|
|
12069
12088
|
users: z.enum(['active', 'new']).optional().describe("'new' = first-time users only (the acquisition funnel); Amplitude's default is active"),
|
|
12070
|
-
segments: z.array(z.object({ prop: z.string().optional(), op: z.string().optional(), values: z.array(z.string()).optional(), type: z.string().optional(), event_type: z.string().optional(), value: z.number().optional(), time_type: z.string().optional(), time_value: z.number().optional() }).passthrough()).optional().describe("FILTER to only these users (Amplitude's segment `s`): [{prop:'gp:utm_campaign', op:'is', values:['summer']}]. op is one of is, is not, contains, does not contain, less, less or equal, greater, greater or equal, set is, set is not. Custom user properties are gp:<name>; built-ins are country, city, region, platform, os, device_type, version, language, paying; prop userdata_cohort + a cohort id filters to a behavioural cohort. A \"who performed\" condition is {type:'event', event_type, op:'>=', value, time_type:'
|
|
12089
|
+
segments: z.array(z.object({ prop: z.string().optional(), op: z.string().optional(), values: z.array(z.string()).optional(), type: z.string().optional(), event_type: z.string().optional(), value: z.number().optional(), time_type: z.string().optional(), time_value: z.number().optional() }).passthrough()).optional().describe("FILTER to only these users (Amplitude's segment `s`): [{prop:'gp:utm_campaign', op:'is', values:['summer']}]. op is one of is, is not, contains, does not contain, less, less or equal, greater, greater or equal, set is, set is not. Custom user properties are gp:<name>; built-ins are country, city, region, platform, os, device_type, version, language, paying; prop userdata_cohort + a cohort id filters to a behavioural cohort. A \"who performed\" condition is {type:'event', event_type, op:'>=', value:1, time_type:'rolling', time_value:30} (did it at least once in the last 30 days); symbol operators only."),
|
|
12071
12090
|
},
|
|
12072
12091
|
outputSchema: { steps: z.array(z.any()).optional(), rows: z.array(z.any()).optional(), conversionWindowSeconds: z.number().optional(), mode: z.string().optional(), raw: z.any().optional(), note: z.string().optional(), note2: z.string().optional() },
|
|
12073
12092
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -12087,7 +12106,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12087
12106
|
brackets: z.array(z.number()).optional().describe('required when retentionMode is "bracket"'),
|
|
12088
12107
|
interval: z.number().optional().describe('1 daily, 7 weekly, 30 monthly'),
|
|
12089
12108
|
groupBy: z.string().optional().describe('ONE property to group by'),
|
|
12090
|
-
segments: z.array(z.object({ prop: z.string().optional(), op: z.string().optional(), values: z.array(z.string()).optional(), type: z.string().optional(), event_type: z.string().optional(), value: z.number().optional(), time_type: z.string().optional(), time_value: z.number().optional() }).passthrough()).optional().describe("FILTER to only these users (Amplitude's segment `s`): [{prop:'gp:utm_campaign', op:'is', values:['summer']}]. op is one of is, is not, contains, does not contain, less, less or equal, greater, greater or equal, set is, set is not. Custom user properties are gp:<name>; built-ins are country, city, region, platform, os, device_type, version, language, paying; prop userdata_cohort + a cohort id filters to a behavioural cohort. A \"who performed\" condition is {type:'event', event_type, op:'>=', value, time_type:'
|
|
12109
|
+
segments: z.array(z.object({ prop: z.string().optional(), op: z.string().optional(), values: z.array(z.string()).optional(), type: z.string().optional(), event_type: z.string().optional(), value: z.number().optional(), time_type: z.string().optional(), time_value: z.number().optional() }).passthrough()).optional().describe("FILTER to only these users (Amplitude's segment `s`): [{prop:'gp:utm_campaign', op:'is', values:['summer']}]. op is one of is, is not, contains, does not contain, less, less or equal, greater, greater or equal, set is, set is not. Custom user properties are gp:<name>; built-ins are country, city, region, platform, os, device_type, version, language, paying; prop userdata_cohort + a cohort id filters to a behavioural cohort. A \"who performed\" condition is {type:'event', event_type, op:'>=', value:1, time_type:'rolling', time_value:30} (did it at least once in the last 30 days); symbol operators only."),
|
|
12091
12110
|
},
|
|
12092
12111
|
outputSchema: { retention: z.any().optional(), raw: z.any().optional(), note: z.string().optional() },
|
|
12093
12112
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -12104,7 +12123,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12104
12123
|
metric: z.enum(['active', 'new']).optional().describe('default active'),
|
|
12105
12124
|
interval: z.number().optional().describe('1 daily, 7 weekly, 30 monthly'),
|
|
12106
12125
|
groupBy: z.string().optional().describe('ONE property to group by'),
|
|
12107
|
-
segments: z.array(z.object({ prop: z.string().optional(), op: z.string().optional(), values: z.array(z.string()).optional(), type: z.string().optional(), event_type: z.string().optional(), value: z.number().optional(), time_type: z.string().optional(), time_value: z.number().optional() }).passthrough()).optional().describe("FILTER to only these users (Amplitude's segment `s`): [{prop:'gp:utm_campaign', op:'is', values:['summer']}]. op is one of is, is not, contains, does not contain, less, less or equal, greater, greater or equal, set is, set is not. Custom user properties are gp:<name>; built-ins are country, city, region, platform, os, device_type, version, language, paying; prop userdata_cohort + a cohort id filters to a behavioural cohort. A \"who performed\" condition is {type:'event', event_type, op:'>=', value, time_type:'
|
|
12126
|
+
segments: z.array(z.object({ prop: z.string().optional(), op: z.string().optional(), values: z.array(z.string()).optional(), type: z.string().optional(), event_type: z.string().optional(), value: z.number().optional(), time_type: z.string().optional(), time_value: z.number().optional() }).passthrough()).optional().describe("FILTER to only these users (Amplitude's segment `s`): [{prop:'gp:utm_campaign', op:'is', values:['summer']}]. op is one of is, is not, contains, does not contain, less, less or equal, greater, greater or equal, set is, set is not. Custom user properties are gp:<name>; built-ins are country, city, region, platform, os, device_type, version, language, paying; prop userdata_cohort + a cohort id filters to a behavioural cohort. A \"who performed\" condition is {type:'event', event_type, op:'>=', value:1, time_type:'rolling', time_value:30} (did it at least once in the last 30 days); symbol operators only."),
|
|
12108
12127
|
},
|
|
12109
12128
|
outputSchema: { series: z.array(z.any()).optional(), xValues: z.array(z.any()).optional(), seriesLabels: z.array(z.any()).optional(), raw: z.any().optional(), note: z.string().optional() },
|
|
12110
12129
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -12805,6 +12824,49 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12805
12824
|
outputSchema: { workspace: z.string().optional(), days: z.number().optional(), read: z.number().optional(), snippets: z.array(z.any()).optional(), note: z.string().optional() },
|
|
12806
12825
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12807
12826
|
}, wrap(async (a) => { const d = await apiPost('/api/intercom/customer-language', a); return ok(`${d.note || ''}${(d.snippets || []).length ? `\n${d.snippets.map((s) => `- "${s.text}"`).join('\n')}` : ''}`, d); }));
|
|
12827
|
+
// ---------- Intercom HELP CENTER + NEWS (2026-10-27): the same /api/intercom routes the Studio tools call ----------
|
|
12828
|
+
// Drafts are written freely; anything a customer SEES (publish, unpublish, a live edit, a delete) needs confirm:true
|
|
12829
|
+
// after the preview the route returns. Every write is read back from Intercom. Rules: lib/intercom-content.mjs.
|
|
12830
|
+
const icArtLine = (x) => `"${x.title || '(untitled)'}" (${x.id}), ${x.state}${x.hasUnpublishedChanges ? ', changes staged' : ''}${x.url ? `: ${x.url}` : ''}`;
|
|
12831
|
+
const icNewsLine = (x) => `"${x.title || '(untitled)'}" (${x.id}), ${x.state}${(x.newsfeedIds || []).length ? ` | newsfeed ${x.newsfeedIds.join(', ')}` : ' | in no newsfeed'}${(x.labels || []).length ? ` | labels: ${x.labels.join(', ')}` : ''}`;
|
|
12832
|
+
const icContentText = (d) => {
|
|
12833
|
+
if (d.dryRun) return `${d.note || ''}\n${d.preview || ''}`;
|
|
12834
|
+
if (d.what === 'articles') return d.count ? `${d.count} Help Center article(s) (page ${d.page} of ${d.totalPages}):\n${d.articles.map((x) => `- ${icArtLine(x)}${x.preview ? `\n ${x.preview}` : ''}`).join('\n')}${d.nextPage ? `\n(more: pass page=${d.nextPage})` : ''}` : 'This Intercom workspace has no Help Center articles. That is a real empty list, not a failed read.';
|
|
12835
|
+
if (d.what === 'collections') return d.count ? `${d.count} collection(s):\n${d.collections.map((c) => `- ${c.name} (${c.id})`).join('\n')}` : 'No Help Center collections yet.';
|
|
12836
|
+
if (d.what === 'article') { const x = d.article || {}; return `${d.draft ? 'STAGED DRAFT of ' : ''}${icArtLine(x)}${x.description ? `\n${x.description}` : ''}\n\n${x.body || '(empty)'}`; }
|
|
12837
|
+
if (d.what === 'news') return d.count ? `${d.count} news item(s) (page ${d.page} of ${d.totalPages}):\n${d.items.map((x) => `- ${icNewsLine(x)}${x.preview ? `\n ${x.preview}` : ''}`).join('\n')}${d.nextPage ? `\n(more: pass page=${d.nextPage})` : ''}` : 'No news items in this Intercom workspace. That is a real empty list, not a failed read.';
|
|
12838
|
+
if (d.what === 'newsfeeds') return d.count ? `${d.count} newsfeed(s): ${d.newsfeeds.map((f) => `${f.name} (${f.id})`).join(', ')}` : 'This Intercom workspace has no newsfeeds.';
|
|
12839
|
+
if (d.what === 'news_item') { const x = d.item || {}; return `${icNewsLine(x)}\n\n${x.body || '(empty)'}`; }
|
|
12840
|
+
return d.note || '';
|
|
12841
|
+
};
|
|
12842
|
+
server.registerTool('list_intercom_articles', {
|
|
12843
|
+
title: 'Read the Intercom Help Center',
|
|
12844
|
+
description: `The brand's Intercom HELP CENTER. what:"articles" (default) lists articles newest-updated first: title, state (published or draft), whether changes are staged on a live article, link, preview. what:"article" + articleId reads one in full; what:"draft" + articleId reads the STAGED changes on a published article; what:"collections" lists the collections an article can sit in. page / perPage to page through. Needs the token's "Read and List articles" permission. Read-only, free. ${icNeeds}`,
|
|
12845
|
+
inputSchema: { what: z.enum(['articles', 'article', 'draft', 'collections']).optional(), articleId: z.string().optional(), page: z.number().optional(), perPage: z.number().optional().describe('1-150, default 25') },
|
|
12846
|
+
outputSchema: { workspace: z.string().optional(), what: z.string().optional(), count: z.number().optional(), articles: z.array(z.any()).optional(), collections: z.array(z.any()).optional(), article: z.any().optional(), nextPage: z.number().nullable().optional() },
|
|
12847
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12848
|
+
}, wrap(async (a) => { const d = await apiGet('/api/intercom/articles', a); return ok(icContentText(d), d); }));
|
|
12849
|
+
server.registerTool('manage_intercom_article', {
|
|
12850
|
+
title: 'Write an Intercom Help Center article',
|
|
12851
|
+
description: `Write a Help Center article in the brand's Intercom. action:"create" makes a DRAFT — title, body (markdown) or bodyHtml, description, collectionId, authorId (default: the connected teammate) — that nobody outside the team sees. action:"update" edits a draft in place; on a PUBLISHED article it STAGES the change as a draft, so the live article is untouched until it is published. action:"publish" puts a draft or the staged changes on the PUBLIC Help Center (and in Fin's answers), "unpublish" takes it off (it stays a draft), "delete" removes it: those three return a preview first and need confirm:true once the user has seen it. Every result is read back from Intercom. Needs "Read and Write Articles". Free. ${icNeeds}`,
|
|
12852
|
+
inputSchema: { action: z.enum(['create', 'update', 'publish', 'unpublish', 'delete']).optional().describe('default: create without articleId, update with one'), articleId: z.string().optional(), title: z.string().optional(), description: z.string().optional(), body: z.string().optional().describe('the article in markdown'), bodyHtml: z.string().optional().describe('the article in HTML, instead of body'), collectionId: z.string().optional(), authorId: z.string().optional(), locales: z.array(z.string()).optional().describe('multilingual Help Centers: the locales whose staged changes to publish'), confirm: z.boolean().optional() },
|
|
12853
|
+
outputSchema: { workspace: z.string().optional(), ok: z.boolean().optional(), action: z.string().optional(), dryRun: z.boolean().optional(), confirmed: z.boolean().optional(), staged: z.boolean().optional(), deleted: z.boolean().optional(), article: z.any().optional(), preview: z.string().optional(), note: z.string().optional() },
|
|
12854
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
12855
|
+
}, wrap(async (a) => { const d = await apiPost('/api/intercom/article', { ...a, confirm: a.confirm === true }); return ok(icContentText(d), d); }));
|
|
12856
|
+
server.registerTool('list_intercom_news', {
|
|
12857
|
+
title: 'Read Intercom news items',
|
|
12858
|
+
description: `The brand's Intercom NEWS — in-app announcements in the Messenger newsfeed. what:"news" (default) lists news items newest first with state (draft or live), newsfeeds and labels; what:"item" + newsItemId reads one in full; what:"newsfeeds" lists the newsfeeds an item can be published to. Needs "Read and list news items and newsfeeds". Read-only, free. ${icNeeds}`,
|
|
12859
|
+
inputSchema: { what: z.enum(['news', 'item', 'newsfeeds']).optional(), newsItemId: z.string().optional(), page: z.number().optional(), perPage: z.number().optional().describe('1-150, default 25') },
|
|
12860
|
+
outputSchema: { workspace: z.string().optional(), what: z.string().optional(), count: z.number().optional(), items: z.array(z.any()).optional(), newsfeeds: z.array(z.any()).optional(), item: z.any().optional(), nextPage: z.number().nullable().optional() },
|
|
12861
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12862
|
+
}, wrap(async (a) => { const d = await apiGet('/api/intercom/news', a); return ok(icContentText(d), d); }));
|
|
12863
|
+
server.registerTool('manage_intercom_news_item', {
|
|
12864
|
+
title: 'Write an Intercom news item',
|
|
12865
|
+
description: `Write an in-app NEWS item (a launch or offer announcement) in the brand's Intercom. action:"create" makes a DRAFT: title, body (plain text) or bodyHtml, labels, reactions (at most 5, one emoji each), coverImageUrl (.png or .jpg), deliverSilently, newsfeedIds, senderId (default: the connected teammate). action:"update" edits it and KEEPS its labels, reactions and newsfeeds unless you pass new ones (Intercom itself would clear them); editing a LIVE item needs confirm:true. action:"publish" (needs newsfeedIds, or a newsfeed it already has), "unpublish" and "delete" return a preview first and need confirm:true once the user has seen it. Read back from Intercom. Needs "Read and write news items and newsfeeds". Free. ${icNeeds}`,
|
|
12866
|
+
inputSchema: { action: z.enum(['create', 'update', 'publish', 'unpublish', 'delete']).optional().describe('default: create without newsItemId, update with one'), newsItemId: z.string().optional(), title: z.string().optional(), body: z.string().optional().describe('plain text'), bodyHtml: z.string().optional(), labels: z.array(z.string()).optional().describe('replaces the labels'), reactions: z.array(z.string()).optional().describe('replaces the reactions; at most 5'), newsfeedIds: z.array(z.string()).optional().describe('replaces the newsfeeds (list_intercom_news what:"newsfeeds")'), coverImageUrl: z.string().optional(), deliverSilently: z.boolean().optional(), senderId: z.string().optional(), confirm: z.boolean().optional() },
|
|
12867
|
+
outputSchema: { workspace: z.string().optional(), ok: z.boolean().optional(), action: z.string().optional(), dryRun: z.boolean().optional(), confirmed: z.boolean().optional(), deleted: z.boolean().optional(), item: z.any().optional(), preview: z.string().optional(), note: z.string().optional() },
|
|
12868
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
12869
|
+
}, wrap(async (a) => { const d = await apiPost('/api/intercom/news-item', { ...a, confirm: a.confirm === true }); return ok(icContentText(d), d); }));
|
|
12808
12870
|
server.group('ads'); // end of the measurement block — back to paid-campaign management
|
|
12809
12871
|
// ---------- Microsoft Advertising (Bing Ads): read + manage. Same spend law as Google — everything is created
|
|
12810
12872
|
// Paused, only an explicit confirm:true arms real money, and every narration comes from a READ-BACK.
|
|
@@ -12817,6 +12879,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12817
12879
|
path1: z.string().optional().describe('display-URL path segment, ≤15 chars, no "/"'),
|
|
12818
12880
|
path2: z.string().optional().describe('second display-URL path segment (only with path1)'),
|
|
12819
12881
|
};
|
|
12882
|
+
// THE AD ACCOUNT IS `adAccountId` (2026-10-27). On /v1 `accountId` names the WORKSPACE and a body may never carry it,
|
|
12883
|
+
// so the Microsoft tools could not name an ad account over REST at all. `accountId` stays as an alias HERE, for MCP
|
|
12884
|
+
// callers written before the rename; /v1 still refuses it by name and the server reads adAccountId first.
|
|
12885
|
+
const msAcct = {
|
|
12886
|
+
adAccountId: z.string().optional().describe('Microsoft ad account id — omit to use the single shared account'),
|
|
12887
|
+
accountId: z.string().optional().describe('older name for adAccountId, still accepted over MCP; over the REST API (/v1) use adAccountId'),
|
|
12888
|
+
};
|
|
12820
12889
|
const msKeywordShape = z.array(z.object({
|
|
12821
12890
|
text: z.string().describe('≤100 characters'),
|
|
12822
12891
|
matchType: z.enum(['Exact', 'Phrase', 'Broad']).optional().describe('default Phrase — Microsoft has no broad-match-modifier'),
|
|
@@ -12825,20 +12894,20 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12825
12894
|
}));
|
|
12826
12895
|
server.registerTool('list_microsoft_ads_campaigns', {
|
|
12827
12896
|
title: 'List Microsoft Advertising accounts / campaigns',
|
|
12828
|
-
description: 'Read the brand’s connected Microsoft Advertising (Bing Ads) account(s). Call with NO
|
|
12897
|
+
description: 'Read the brand’s connected Microsoft Advertising (Bing Ads) account(s). Call with NO adAccountId to list the accounts shared with this profile — do this first to pick a target. Call WITH adAccountId to list that account’s campaigns (id, name, status, daily budget, campaign type, and whether the budget is SHARED). Microsoft statuses are Active / Paused — never Google’s ENABLED — and Microsoft also sets BudgetPaused, BudgetAndManualPaused and Suspended on its own, so report the status you read rather than assuming a paused campaign was paused by a person. Read-only, free. Needs Microsoft Advertising connected (Settings > Connectors > Microsoft Advertising).',
|
|
12829
12898
|
inputSchema: {
|
|
12830
|
-
|
|
12899
|
+
...msAcct,
|
|
12831
12900
|
},
|
|
12832
12901
|
outputSchema: { accounts: z.array(z.any()).optional(), accountId: z.string().optional(), count: z.number().optional(), campaigns: z.array(z.any()).optional(), allCampaignTypes: z.boolean().optional() },
|
|
12833
12902
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12834
12903
|
}, wrap(async (a) => {
|
|
12835
|
-
if (!a.accountId) {
|
|
12904
|
+
if (!(a.adAccountId || a.accountId)) {
|
|
12836
12905
|
const d = await apiGet('/api/microsoft-ads/shared-accounts', {});
|
|
12837
12906
|
if (!(d.accounts || []).length) return ok('No Microsoft Advertising account is shared with this profile yet. Ask the user to tick the ones it may use in Settings ▸ Connectors ▸ Microsoft Ads ▸ Manage accounts (or call set_connector_accounts), then retry.', d);
|
|
12838
12907
|
const lines = (d.accounts || []).map(c => `• ${c.name} (${c.accountId})${c.number ? ` — ${c.number}` : ''}${c.selected ? ' [shared with this profile]' : ''}${c.status ? ` · ${c.status}` : ''}`);
|
|
12839
|
-
return ok(`${(d.accounts || []).length} Microsoft Advertising account(s) shared with this profile${d.sandbox ? ' (SANDBOX environment)' : ''}:\n${lines.join('\n') || '(none)'}\nPass an
|
|
12908
|
+
return ok(`${(d.accounts || []).length} Microsoft Advertising account(s) shared with this profile${d.sandbox ? ' (SANDBOX environment)' : ''}:\n${lines.join('\n') || '(none)'}\nPass an adAccountId to see its campaigns. Only accounts ticked in Settings ▸ Connectors can be managed.`, d);
|
|
12840
12909
|
}
|
|
12841
|
-
const d = await apiGet('/api/microsoft-ads/campaigns', {
|
|
12910
|
+
const d = await apiGet('/api/microsoft-ads/campaigns', { adAccountId: (a.adAccountId || a.accountId) });
|
|
12842
12911
|
const lines = (d.campaigns || []).map(c => `• ${c.name} (${c.id}) — ${c.status}${c.dailyBudget != null ? `, ${c.dailyBudget}/day${c.sharedBudget ? ' (SHARED budget — read-only here)' : ''}` : ''}${c.type ? `, ${c.type}` : ''}`);
|
|
12843
12912
|
return ok(`${d.count} campaign(s) on Microsoft Advertising account ${d.accountId}:\n${lines.join('\n') || '(none)'}${d.allCampaignTypes === false ? '\nNOTE: only SEARCH campaigns are listed — Microsoft refused the all-types filter, so other campaign types may exist.' : ''}`, d);
|
|
12844
12913
|
}));
|
|
@@ -12846,7 +12915,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12846
12915
|
title: 'Microsoft Advertising performance report',
|
|
12847
12916
|
description: 'Performance for a Microsoft Advertising account — impressions, clicks, CTR, average CPC, spend, conversions. `reportType` picks WHICH report, and that is the whole Microsoft reporting surface, not just campaigns: AdGroupPerformance, AdPerformance, KeywordPerformance, SearchQueryPerformance (the actual search terms people typed), GeographicPerformance, UserLocationPerformance, AgeGenderAudience and ProfessionalDemographicsAudience (LinkedIn-sourced job function and industry, inside Bing), ConversionPerformance, DestinationUrlPerformance, ShareOfVoice, AssetPerformance, ProductDimensionPerformance, SearchCampaignChangeHistory ("what changed on Tuesday") and ~30 more — an unknown name is refused WITH the full list rather than forwarded. `aggregation` controls the row grain (Summary / Daily / Hourly / Weekly / Monthly / Yearly / HourOfDay / DayOfWeek). Two reports keep far less history than the usual 36 months — AssetPerformance 30 days, ShareOfVoice 6 — and the reply says so, because an empty short-retention report is a retention limit, not an absence of delivery. Window via timePeriod (Today | Yesterday | LastSevenDays | Last14Days | Last30Days | ThisWeek | LastWeek | LastFourWeeks | ThisMonth | LastMonth | LastThreeMonths | LastSixMonths | ThisYear | LastYear | ThisWeekStartingMonday | LastWeekStartingMonday | LastFourWeeksStartingMonday) or since+until (YYYY-MM-DD) — default Last30Days. An unrecognised timePeriod is REFUSED, never silently swapped for another window. Microsoft generates reports ASYNCHRONOUSLY: this can return pending:true with a reportRequestId, and you must call again rather than reporting any numbers. A report that succeeds with ZERO rows genuinely means there was no delivery in that window — say exactly that; never present zeros as measured performance. Read-only, free.',
|
|
12848
12917
|
inputSchema: {
|
|
12849
|
-
|
|
12918
|
+
...msAcct,
|
|
12850
12919
|
timePeriod: z.string().optional().describe('predefined Microsoft window, default Last30Days — must be one of the values in the description; anything else is rejected'),
|
|
12851
12920
|
since: z.string().optional().describe('YYYY-MM-DD custom range start (with until)'),
|
|
12852
12921
|
until: z.string().optional().describe('YYYY-MM-DD custom range end'),
|
|
@@ -12870,7 +12939,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12870
12939
|
title: 'Microsoft Advertising keyword planner',
|
|
12871
12940
|
description: 'Microsoft Advertising\u2019s KEYWORD PLANNER — real monthly search volume, competition, suggested bid and ad impression share, expanded from seed keywords, a landing-page URL to mine, or a category. Run it BEFORE choosing keywords for a Microsoft campaign. locationIds is REQUIRED and deliberately not defaulted: a search volume with no market attached is a number nobody can act on, and inventing a country would silently answer about the wrong market — use microsoft_ads_geo_search to resolve a country or city name to an id, free. SAY THIS WHEN REPORTING: Competition is Microsoft\u2019s Low/Medium/High bucket, NOT a percentage; MonthlySearchCounts is a per-month series rather than one number; SuggestedBid is in the account currency. An empty result means Microsoft found no ideas for those seeds, never that nobody searches for them. Read-only, 0 credits.',
|
|
12872
12941
|
inputSchema: {
|
|
12873
|
-
|
|
12942
|
+
...msAcct,
|
|
12874
12943
|
keywords: z.array(z.string()).optional().describe('seed terms to expand from'),
|
|
12875
12944
|
url: z.string().optional().describe('a landing page for Microsoft to mine ideas from'),
|
|
12876
12945
|
categoryId: z.number().optional(),
|
|
@@ -12892,7 +12961,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12892
12961
|
title: 'Microsoft Advertising traffic estimates',
|
|
12893
12962
|
description: 'What a set of keywords would DELIVER on Microsoft Advertising at a given bid — estimated impressions, clicks, CTR, average CPC, average position and total cost. maxCpc is REQUIRED because a traffic estimate IS a function of the bid; estimating without one would be inventing the input. locationIds is REQUIRED for the same reason a search volume needs a market. SAY THIS WHEN REPORTING: Microsoft returns a MINIMUM and a MAXIMUM per keyword — quote the range, never average the two into a single figure — and every number here is a FORECAST, so never present it as measured performance. Read-only, 0 credits.',
|
|
12894
12963
|
inputSchema: {
|
|
12895
|
-
|
|
12964
|
+
...msAcct,
|
|
12896
12965
|
keywords: z.array(z.string()).describe('the keywords to estimate'),
|
|
12897
12966
|
maxCpc: z.number().describe('REQUIRED \u2014 the max CPC bid to estimate at, in the account currency'),
|
|
12898
12967
|
matchType: z.enum(['Exact', 'Phrase', 'Broad']).optional().describe('default Exact'),
|
|
@@ -12908,18 +12977,18 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12908
12977
|
server.registerTool('microsoft_ads_budget_opportunities', {
|
|
12909
12978
|
title: 'Where Microsoft says budget is capping delivery',
|
|
12910
12979
|
description: 'Where a Microsoft Advertising campaign is BUDGET-CONSTRAINED — Microsoft\u2019s own recommended budget against the current one, the estimated WEEKLY click and impression gain from raising it, and a budget/return curve. Omit campaignId for the whole account. SAY THIS WHEN REPORTING: these are Microsoft\u2019s FORECASTS, never measurements — a projected increase has not happened — and acting on one spends real money, so it takes set_microsoft_ads_budget and an explicit yes from the user. Microsoft EXCLUDES user-paused campaigns from this analysis, so a paused campaign is absent by design rather than well-funded. Read-only, 0 credits.',
|
|
12911
|
-
inputSchema: {
|
|
12980
|
+
inputSchema: { ...msAcct, campaignId: z.string().optional().describe('omit for the whole account') },
|
|
12912
12981
|
outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), opportunities: z.array(z.any()).optional(), note: z.string().optional() },
|
|
12913
12982
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
12914
12983
|
}, wrap(async (a) => {
|
|
12915
|
-
const d = await apiGet('/api/microsoft-ads/budget-opportunities', {
|
|
12984
|
+
const d = await apiGet('/api/microsoft-ads/budget-opportunities', { adAccountId: (a.adAccountId || a.accountId), campaignId: a.campaignId });
|
|
12916
12985
|
return ok(`${d.note}\n${JSON.stringify(d.opportunities || []).slice(0, 4000)}`, d);
|
|
12917
12986
|
}));
|
|
12918
12987
|
server.registerTool('microsoft_ads_geo_search', {
|
|
12919
12988
|
title: 'Find Microsoft Advertising location ids',
|
|
12920
12989
|
description: 'Resolve country / region / city names to the Microsoft Advertising location ids that create_microsoft_ads_campaign needs. Read-only, free, 0 credits. Use it when a location ask is ambiguous ("Springfield") — this returns EVERY candidate with its id so the USER can pick, and you never guess between two places. Accepts names, ISO country codes ("CA"), or numeric location ids. Pass `query` as ONE ask (a plain string) or SEVERAL (an array of strings) — a comma is part of a place\'s name ("Seattle, Washington, United States"), never a separator. Postal codes and neighbourhoods are not name-searchable — pass their numeric location id straight through; the campaign read-back reports the name Microsoft resolves for it.',
|
|
12921
12990
|
inputSchema: {
|
|
12922
|
-
|
|
12991
|
+
...msAcct,
|
|
12923
12992
|
// A STRING IS ACCEPTED, not only an array. The server has always normalized a lone string, but the schema
|
|
12924
12993
|
// REJECTED one before it reached the server \u2014 so the obvious single-location call ("Canada") failed validation
|
|
12925
12994
|
// on a parameter whose array-ness nothing explained. Commas are never split: they occur INSIDE real Microsoft
|
|
@@ -12936,7 +13005,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12936
13005
|
title: 'Build a Microsoft Advertising campaign (paused)',
|
|
12937
13006
|
description: 'Build a campaign on a connected Microsoft Advertising (Bing Ads) account. ALWAYS created Paused — it spends NOTHING until you activate it with set_microsoft_ads_status(confirm:true). Microsoft’s object graph is campaign -> ad group -> responsive search ad -> keywords, so a campaign ON ITS OWN CANNOT SERVE AN IMPRESSION: pass adGroup{name, ad{headlines,descriptions,finalUrls}, keywords[]} and this builds the whole tree. Microsoft has NO atomic multi-object write, so the levels are created in sequence and the campaign is DELETED again if anything below it is rejected — you never inherit a half-built campaign. Microsoft requires 3–15 headlines (≤30 chars) and 2–4 descriptions (≤90 chars); expanded text ads can no longer be created at all. dailyBudget is in the ACCOUNT’S currency, not necessarily USD. LOCATION TARGETING: pass locations[] (country / region / city names, ISO country codes, or numeric Microsoft location ids). A Microsoft campaign has NO geo targeting unless it is set, and Microsoft does not require any — so if you pass none, the campaign IS CREATED and serves WORLDWIDE (Microsoft’s own default), and the returned note says so loudly. That is safe at this stage because the campaign is Paused and spends nothing; it is NOT safe to activate without telling the user, so relay the warning. Nothing is created when a location you DID name cannot be resolved (call microsoft_ads_geo_search to disambiguate, then pass the id). Pass worldwide:true to record that everywhere was deliberate and suppress the nudge. The locations are written and READ BACK inside the same rollback as the rest of the tree, so a campaign is either targeted as asked or does not exist. Everything is READ BACK from Microsoft before you are told it exists; print the returned note verbatim, and if it says the campaign cannot serve yet, say that rather than calling it a finished ad.',
|
|
12938
13007
|
inputSchema: {
|
|
12939
|
-
|
|
13008
|
+
...msAcct,
|
|
12940
13009
|
name: z.string().describe('campaign name, ≤128 characters'),
|
|
12941
13010
|
dailyBudget: z.number().describe('daily budget in the account’s currency'),
|
|
12942
13011
|
budgetType: z.enum(['DailyBudgetStandard', 'DailyBudgetAccelerated', 'LifetimeBudgetStandard']).optional().describe('default DailyBudgetStandard; Accelerated is Audience-campaign only'),
|
|
@@ -12947,11 +13016,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12947
13016
|
feedLabel: z.string().optional().describe('Shopping: advertise every product with this feed label (≤20 characters). Not with salesCountryCode.'),
|
|
12948
13017
|
localInventoryAds: z.boolean().optional().describe('Shopping: enable local inventory ads (only where Microsoft has enabled the feature for the account)'),
|
|
12949
13018
|
dynamicSearchAds: z.object({ domainName: z.string().describe('the website, e.g. "example.com"'), language: z.string().describe('the pages\u2019 language: Dutch, English, French, German, Italian, Spanish or Swedish'), source: z.enum(['SystemIndex', 'AdvertiserSuppliedUrls', 'All']).optional(), dynamicDescriptionEnabled: z.boolean().optional() }).optional().describe('Dynamic search ads: required for campaignType DynamicSearchAds, optional on Search. Microsoft retired the DynamicSearchAds TYPE, so the campaign is a Search campaign carrying this setting.'),
|
|
12950
|
-
biddingScheme: z.enum(['MaxConversions', 'MaxConversionValue']).optional().describe('Performance Max
|
|
13019
|
+
biddingScheme: z.enum(['EnhancedCpc', 'MaxClicks', 'MaxConversions', 'MaxConversionValue', 'TargetImpressionShare', 'TargetCpa', 'TargetRoas']).optional().describe('bid strategy. Search takes any; Audience EnhancedCpc/MaxConversions; Shopping EnhancedCpc; Performance Max MaxConversions/MaxConversionValue. TargetCpa/TargetRoas are sent as MaxConversions/MaxConversionValue with the target (Microsoft converts them silently since 2025-08-04). Omitted = Microsoft\u2019s default, read back in the reply. Change it later with set_microsoft_ads_bidding'),
|
|
13020
|
+
maxCpc: z.number().optional().describe('bid cap for MaxClicks / MaxConversions / MaxConversionValue / TargetImpressionShare (not Performance Max)'),
|
|
13021
|
+
targetImpressionShare: z.number().optional().describe('1–100, with biddingScheme TargetImpressionShare'),
|
|
13022
|
+
targetAdPosition: z.enum(['FirstPage', 'MainLine', 'MainLine1']).optional().describe('with TargetImpressionShare; MainLine1 = the very top'),
|
|
12951
13023
|
targetCpa: z.number().optional().describe('Performance Max with MaxConversions: target cost per acquisition, account currency'),
|
|
12952
13024
|
targetRoas: z.number().optional().describe('Performance Max with MaxConversionValue: target return on ad spend as a ratio, e.g. 4 = 400%'),
|
|
12953
13025
|
finalUrlExpansionOptOut: z.boolean().optional().describe('Performance Max: true = serve only the final URLs you give; default false lets Microsoft use the whole domain'),
|
|
12954
|
-
timeZone: z.string().optional().describe('Microsoft time-zone enum
|
|
13026
|
+
timeZone: z.string().optional().describe('Microsoft time-zone enum (e.g. EasternTimeUSCanada); default the ad account\u2019s own. Microsoft marks a campaign time zone deprecated and may keep its own — the reply states the zone it stored'),
|
|
12955
13027
|
locations: z.array(z.string()).optional().describe('where the ads may serve — omit for worldwide (Microsoft’s default, warned about in the read-back), e.g. ["United States"] or ["Seattle, Washington, United States","CA"]. Resolved to Microsoft location ids BEFORE anything is created; an ambiguous or unknown one refuses the whole create and names it'),
|
|
12956
13028
|
excludeLocations: z.array(z.string()).optional().describe('locations to EXCLUDE from the targeted set'),
|
|
12957
13029
|
locationIntent: z.enum(['PeopleInOrSearchingForOrViewingPages', 'PeopleIn']).optional().describe('default PeopleInOrSearchingForOrViewingPages — someone OUTSIDE the target still sees the ad if they search for the place; PeopleIn restricts to people physically there'),
|
|
@@ -12969,7 +13041,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12969
13041
|
title: 'Add a Microsoft Advertising ad group',
|
|
12970
13042
|
description: 'Add an ad group to an existing Microsoft Advertising campaign — optionally with its responsive search ad and keywords in the same call. Created Paused by default. If the parent campaign is already LIVE (Active), creating this ad group Active starts REAL AD SPEND on the next auction, exactly like activating it — show the user what would begin serving, get an explicit yes, then pass confirm:true. Leaving it Paused never needs confirmation. Read back from Microsoft before you are told it exists.',
|
|
12971
13043
|
inputSchema: {
|
|
12972
|
-
|
|
13044
|
+
...msAcct,
|
|
12973
13045
|
campaignId: z.string().describe('the campaign this ad group belongs to'),
|
|
12974
13046
|
name: z.string().describe('ad group name, ≤256 characters'),
|
|
12975
13047
|
status: z.enum(['Active', 'Paused']).optional().describe('default Paused'),
|
|
@@ -12989,7 +13061,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12989
13061
|
title: 'Create a Microsoft responsive search ad',
|
|
12990
13062
|
description: 'Create the actual AD inside a Microsoft Advertising ad group — this is the object that carries the creative; a campaign or ad group alone shows nothing. It builds a RESPONSIVE SEARCH AD: 3–15 headlines (≤30 chars), 2–4 descriptions (≤90 chars), at least one finalUrl, optional path1/path2. Expanded text ads CANNOT be created any more — Microsoft rejects them outright. Created Paused; if the parent ad group and campaign are both Active, creating this Active starts REAL AD SPEND immediately — get an explicit yes and pass confirm:true. Read back from Microsoft, including its editorial status, before you are told it exists.',
|
|
12991
13063
|
inputSchema: {
|
|
12992
|
-
|
|
13064
|
+
...msAcct,
|
|
12993
13065
|
adGroupId: z.string().describe('the ad group this ad lives in'),
|
|
12994
13066
|
...msAdShape,
|
|
12995
13067
|
status: z.enum(['Active', 'Paused']).optional().describe('default Paused'),
|
|
@@ -13005,7 +13077,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13005
13077
|
title: 'Add Microsoft Advertising keywords',
|
|
13006
13078
|
description: 'Add keywords to a Microsoft Advertising ad group. Match types are Exact, Phrase and Broad — Microsoft has no broad-match-modifier. Keywords are added Paused unless you set status:"Active"; an Active keyword on a live ad group makes the campaign bid on a new term immediately, so that needs confirm:true. Note that per-keyword bids are honoured but ad-group / keyword BID STRATEGIES are silently ignored by Microsoft — they inherit the campaign’s. Only the keywords Microsoft confirms on the read-back are reported as added.',
|
|
13007
13079
|
inputSchema: {
|
|
13008
|
-
|
|
13080
|
+
...msAcct,
|
|
13009
13081
|
adGroupId: z.string().describe('the ad group to add them to'),
|
|
13010
13082
|
keywords: msKeywordShape.describe('the keywords'),
|
|
13011
13083
|
confirm: z.boolean().optional().describe('REQUIRED true to add an Active keyword to a LIVE ad group'),
|
|
@@ -13020,7 +13092,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13020
13092
|
title: 'Find Microsoft Advertising LinkedIn profile ids',
|
|
13021
13093
|
description: 'Find the LinkedIn profile ids Microsoft Advertising can target: a company (CompanyName), an Industry or a JobFunction. A company is found with Microsoft’s own company search (3+ characters); industries and job functions are matched against the profile list Microsoft publishes for them. Microsoft publishes no lookup for JobTitle or JobSeniority ids, so those cannot be searched here; an id you already have can still be targeted. Read-only, free. Pass the ids to set_microsoft_ads_profile_targeting.',
|
|
13022
13094
|
inputSchema: {
|
|
13023
|
-
|
|
13095
|
+
...msAcct,
|
|
13024
13096
|
profileType: z.enum(['CompanyName', 'Industry', 'JobFunction']),
|
|
13025
13097
|
query: z.string().describe('part of the name to find (3+ characters for a company)'),
|
|
13026
13098
|
languageLocale: z.string().optional().describe('en (default), fr, de, it, pt-BR, es or zh-Hant'),
|
|
@@ -13032,7 +13104,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13032
13104
|
title: 'Read a Microsoft Advertising campaign’s LinkedIn profile targeting',
|
|
13033
13105
|
description: 'Read the LinkedIn profile targeting on a Microsoft Advertising campaign: every company, industry, job function, seniority and job title criterion with its bid adjustment and criterion id, read from Microsoft. A profile type Microsoft would not read is named as unread, never reported as none. Read-only, free.',
|
|
13034
13106
|
inputSchema: {
|
|
13035
|
-
|
|
13107
|
+
...msAcct,
|
|
13036
13108
|
campaignId: z.string(),
|
|
13037
13109
|
},
|
|
13038
13110
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -13041,7 +13113,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13041
13113
|
title: 'Set LinkedIn profile targeting on a Microsoft Advertising campaign',
|
|
13042
13114
|
description: 'Target LinkedIn profiles on a Microsoft Advertising Search, Shopping or Dynamic Search Ads campaign: add companies, industries, job functions, seniorities or job titles, each with an optional bid adjustment (whole percent, -90 to 900), or change an existing adjustment by criterion id (list_microsoft_ads_profile_targeting shows it). Find profile ids with search_microsoft_ads_profiles. On an ACTIVE campaign this changes bids on the very next auction, so show the user the changes, get an explicit yes, then call with confirm:true. The reply is what Microsoft reads back, and a change that does not read back is named. Removing a profile criterion is not available here; an adjustment of 0 has no effect on bids.',
|
|
13043
13115
|
inputSchema: {
|
|
13044
|
-
|
|
13116
|
+
...msAcct,
|
|
13045
13117
|
campaignId: z.string(),
|
|
13046
13118
|
add: z.array(z.object({ profileType: z.enum(['CompanyName', 'Industry', 'JobFunction', 'JobSeniority', 'JobTitle']), profileId: z.string(), bidAdjustment: z.number().optional().describe('whole percent, -90 to 900; omit for none') })).optional(),
|
|
13047
13119
|
update: z.array(z.object({ criterionId: z.string(), bidAdjustment: z.number().describe('whole percent, -90 to 900') })).optional(),
|
|
@@ -13053,7 +13125,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13053
13125
|
title: 'Set a Microsoft Advertising daily budget',
|
|
13054
13126
|
description: 'Change a Microsoft Advertising campaign’s DAILY BUDGET (in the account’s currency). Raising it on a LIVE (Active) campaign increases real spend immediately — you MUST show the user the new daily amount, get an explicit yes, then call with confirm:true. If the campaign is on a SHARED budget its amount is read-only here and this refuses with an explanation rather than pretending to change it. Read back after the change.',
|
|
13055
13127
|
inputSchema: {
|
|
13056
|
-
|
|
13128
|
+
...msAcct,
|
|
13057
13129
|
campaignId: z.string().describe('the campaign whose budget changes'),
|
|
13058
13130
|
dailyBudget: z.number().describe('new daily budget in the account’s currency'),
|
|
13059
13131
|
confirm: z.boolean().optional().describe('REQUIRED true to change the budget of a LIVE campaign'),
|
|
@@ -13068,7 +13140,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13068
13140
|
title: 'Activate or pause a Microsoft Advertising campaign / ad group / ad',
|
|
13069
13141
|
description: 'Turn a Microsoft Advertising campaign, AD GROUP or AD on (Active) or off (Paused). Pass level:"campaign" + campaignId, level:"adGroup" + adGroupId, or level:"ad" + BOTH adGroupId and adId. ACTIVATING STARTS REAL AD SPEND — you MUST first show the user the campaign name + its daily budget, get an explicit yes, then call with status:"Active" and confirm:true. Pausing is always safe. Microsoft has only these two statuses — its Deleted state is internal-only and cannot be SET — so to remove something use delete_microsoft_ads_object, which is a real delete operation, not a status. The resulting status is READ BACK from Microsoft before you are told it took — and Microsoft may report BudgetPaused / BudgetAndManualPaused / Suspended instead, which the note names explicitly.',
|
|
13070
13142
|
inputSchema: {
|
|
13071
|
-
|
|
13143
|
+
...msAcct,
|
|
13072
13144
|
level: z.enum(['campaign', 'adGroup', 'ad']).optional().describe('what to change — default campaign'),
|
|
13073
13145
|
campaignId: z.string().optional().describe('campaign id (level:"campaign")'),
|
|
13074
13146
|
adGroupId: z.string().optional().describe('ad group id (level:"adGroup", or with adId for level:"ad")'),
|
|
@@ -13088,7 +13160,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13088
13160
|
title: 'Delete a Microsoft Advertising campaign / ad group / ad / keyword',
|
|
13089
13161
|
description: 'PERMANENTLY delete a Microsoft Advertising campaign, ad group, ad or keyword. This is a real delete — Microsoft removes the object and it stops being returned by every read, with no undelete and no documented recovery window. Pass level:"campaign" + campaignId, level:"adGroup" + adGroupId, level:"ad" + adGroupId AND adId, or level:"keyword" + adGroupId AND keywordId. CALL IT WITHOUT confirm FIRST: nothing is deleted, and you get back the object’s real name, its status and how many ad groups / ads / keywords go with it, read live from Microsoft — show the user exactly that. If the object has children, is Active, or has spent, confirming alone is NOT enough: you must also pass confirmName set to its exact name and confirmChildren set to the child count from that read-back, which is what proves you are deleting the object you think you are. A campaign that is paused, empty and never ran deletes on plain confirm:true. To stop delivery WITHOUT deleting, use set_microsoft_ads_status(status:"Paused") instead. The result is READ BACK from Microsoft: it says "deleted" only when the object no longer resolves, "not confirmed" if it does, and "could not tell" if the check itself failed — repeat that verbatim rather than claiming success.',
|
|
13090
13162
|
inputSchema: {
|
|
13091
|
-
|
|
13163
|
+
...msAcct,
|
|
13092
13164
|
level: z.enum(['campaign', 'adGroup', 'ad', 'keyword']).optional().describe('what to delete — default campaign'),
|
|
13093
13165
|
campaignId: z.string().optional().describe('campaign id (level:"campaign"; also the parent for level:"adGroup" if you know it)'),
|
|
13094
13166
|
adGroupId: z.string().optional().describe('ad group id (level:"adGroup"; REQUIRED as the parent for level:"ad" and level:"keyword")'),
|
|
@@ -13118,7 +13190,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13118
13190
|
title: "List Microsoft Merchant Center stores",
|
|
13119
13191
|
description: "List the brand's MICROSOFT MERCHANT CENTER stores \u2014 the product-feed side of Microsoft Advertising, the exact twin of Google Merchant Center. THIS IS WHERE THE merchantId COMES FROM: every other Microsoft Merchant tool needs it and there is no other way to discover it (Content API's own Store resource is closed-beta only, so Hermoso reads the list from Campaign Management instead, which is open to everyone). Each row carries the store name, its URL, and whether it is active, has a catalog and has product ads enabled. Microsoft's own caveat, which the note repeats: those flags are set inside Merchant Center and are read-only here \u2014 an inactive store can still be referenced by a Shopping campaign. An account with NO store has no product feed at all, and a store cannot be created through the API. Read-only, free, 0 credits.",
|
|
13120
13192
|
inputSchema: {
|
|
13121
|
-
|
|
13193
|
+
...msAcct,
|
|
13122
13194
|
},
|
|
13123
13195
|
outputSchema: { ok: z.boolean().optional(), accountId: z.string().optional(), customerId: z.string().optional(), count: z.number().optional(), stores: z.array(z.any()).optional(), note: z.string().optional() },
|
|
13124
13196
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
@@ -13130,7 +13202,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13130
13202
|
title: "List Microsoft Merchant Center product offers",
|
|
13131
13203
|
description: "Read the product offers in a Microsoft Merchant Center store \u2014 the feed a Microsoft Shopping campaign serves from. Pass productId to fetch ONE offer, or omit it to page the store (limit up to 250, then pass the returned pageToken). MICROSOFT'S PRODUCT IDS ARE COMPOSED AND CASE SENSITIVE: the id is channel:contentLanguage:targetCountry:offerId (e.g. Online:en:US:Sku123), not the bare offerId, and a re-cased id addresses nothing \u2014 always use the id this tool returned. Read-only, free, 0 credits.",
|
|
13132
13204
|
inputSchema: {
|
|
13133
|
-
|
|
13205
|
+
...msAcct,
|
|
13134
13206
|
merchantId: z.string().optional().describe('Microsoft Merchant Center store id from list_microsoft_merchant_stores \u2014 omit when the account has exactly one store'),
|
|
13135
13207
|
productId: z.string().optional().describe('fully qualified id (channel:contentLanguage:targetCountry:offerId, e.g. Online:en:US:Sku123) to fetch ONE offer'),
|
|
13136
13208
|
limit: z.number().optional().describe('up to 250, default 25'),
|
|
@@ -13146,7 +13218,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13146
13218
|
title: "Add or update Microsoft Merchant Center product offers",
|
|
13147
13219
|
description: "Add or update product offers in a Microsoft Merchant Center store. THE ONE THING TO KNOW BEFORE CALLING IT: Microsoft has NO partial update \u2014 'because an update is an insert operation, you must include all fields of the offer in the request' \u2014 so a title-only 'update' CLEARS every other field. Always send the whole offer. Ten fields are required: availability, channel, condition, contentLanguage, imageLink, link, offerId, price, targetCountry, title; brand/gtin/mpn are strongly recommended and their absence is set as identifierExists:false for you. Pass product for one offer or products[] for a batch (Hermoso caps a batch at 300 \u2014 Microsoft publishes two contradictory ceilings for this and we take the lower). Pass dryRun:true to validate against Microsoft without writing anything; Microsoft returns no ids on a dry run, so none is claimed. Feed edits cannot spend and cannot start an auction, but an offer goes through editorial review \u2014 being STORED is not the same as being served. Everything is read back from Microsoft. 0 credits.",
|
|
13148
13220
|
inputSchema: {
|
|
13149
|
-
|
|
13221
|
+
...msAcct,
|
|
13150
13222
|
merchantId: z.string().optional().describe('Microsoft Merchant Center store id from list_microsoft_merchant_stores \u2014 omit when the account has exactly one store'),
|
|
13151
13223
|
product: z.record(z.any()).optional().describe('ONE whole offer \u2014 availability, channel, condition, contentLanguage, imageLink, link, offerId, price{value,currency}, targetCountry, title are required; brand/gtin/mpn strongly recommended'),
|
|
13152
13224
|
products: z.array(z.record(z.any())).optional().describe('several whole offers in one batch, maximum 300'),
|
|
@@ -13163,7 +13235,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13163
13235
|
title: "Delete a Microsoft Merchant Center product offer",
|
|
13164
13236
|
description: "Delete one product offer from a Microsoft Merchant Center store. Pass productId as the FULLY QUALIFIED id (channel:contentLanguage:targetCountry:offerId), not the offerId. Call without confirm first \u2014 nothing is deleted and you get Microsoft's own better advice back: deleted products can take UP TO 12 HOURS to stop delivering, so if the goal is to stop showing it today, upsert it with availability 'out of stock' instead. The outcome is READ BACK by re-fetching the offer, and says 'deleted', 'not confirmed' or 'could not tell' \u2014 repeat that verbatim rather than claiming success from the delete's own empty response. 0 credits.",
|
|
13165
13237
|
inputSchema: {
|
|
13166
|
-
|
|
13238
|
+
...msAcct,
|
|
13167
13239
|
merchantId: z.string().optional().describe('Microsoft Merchant Center store id from list_microsoft_merchant_stores \u2014 omit when the account has exactly one store'),
|
|
13168
13240
|
productId: z.string().describe('the FULLY QUALIFIED id (channel:contentLanguage:targetCountry:offerId), not the offerId'),
|
|
13169
13241
|
confirm: z.boolean().optional().describe('REQUIRED true \u2014 call without it first to see Microsoft\u2019s own advice about the 12-hour delivery tail'),
|
|
@@ -13178,7 +13250,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13178
13250
|
title: "Why Microsoft Merchant Center products are not serving",
|
|
13179
13251
|
description: "Why a Microsoft Merchant Center store's products are NOT serving \u2014 the approved / disapproved / expiring counts plus the per-offer disapprovals and warnings with Microsoft's own issue codes. This is the tool to reach for when a Microsoft Shopping campaign is live and showing nothing. TWO CAVEATS THAT LOOK LIKE FAILURES AND ARE NOT, both repeated in the note: Microsoft returns detail rows ONLY for offers that are Disapproved or in Warning, so an EMPTY detail list is the healthy state; and a status change takes up to two hours to reach the summary, so a fresh upload legitimately shows as nothing yet. Read-only, free, 0 credits.",
|
|
13180
13252
|
inputSchema: {
|
|
13181
|
-
|
|
13253
|
+
...msAcct,
|
|
13182
13254
|
merchantId: z.string().optional().describe('Microsoft Merchant Center store id from list_microsoft_merchant_stores \u2014 omit when the account has exactly one store'),
|
|
13183
13255
|
limit: z.number().optional().describe('up to 250, default 25'),
|
|
13184
13256
|
pageToken: z.string().optional(),
|
|
@@ -13193,7 +13265,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13193
13265
|
title: "List Microsoft Merchant Center catalogs",
|
|
13194
13266
|
description: "List the catalogs inside a Microsoft Merchant Center store. Catalogs logically group products, and a Shopping campaign's product scope points at them. Each row says whether publishing is ENABLED \u2014 products in a catalog with publishing off do not serve at all, which is a common and invisible reason a feed appears healthy and delivers nothing. Products go into the store's default catalog unless a call names catalogId. Read-only, free, 0 credits.",
|
|
13195
13267
|
inputSchema: {
|
|
13196
|
-
|
|
13268
|
+
...msAcct,
|
|
13197
13269
|
merchantId: z.string().optional().describe('Microsoft Merchant Center store id from list_microsoft_merchant_stores \u2014 omit when the account has exactly one store'),
|
|
13198
13270
|
},
|
|
13199
13271
|
outputSchema: { ok: z.boolean().optional(), merchantId: z.string().optional(), count: z.number().optional(), catalogs: z.array(z.any()).optional(), note: z.string().optional() },
|
|
@@ -13206,7 +13278,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13206
13278
|
title: "Create, update or delete a Microsoft Merchant Center catalog",
|
|
13207
13279
|
description: "Create, update or delete a catalog in a Microsoft Merchant Center store. Creating needs name + market + isPublishingEnabled; updating needs catalogId + name + isPublishingEnabled (market is not settable after creation). isPublishingEnabled is NEVER defaulted on \u2014 products serve only when it is true, so publishing a catalogue is always an explicit choice. Names must be unique within the store and are capped at 70 characters. Pass deleteIt:true with catalogId and confirm:true to remove one. Every outcome is READ BACK from Microsoft. 0 credits.",
|
|
13208
13280
|
inputSchema: {
|
|
13209
|
-
|
|
13281
|
+
...msAcct,
|
|
13210
13282
|
merchantId: z.string().optional().describe('Microsoft Merchant Center store id from list_microsoft_merchant_stores \u2014 omit when the account has exactly one store'),
|
|
13211
13283
|
catalogId: z.string().optional().describe('set to UPDATE or DELETE an existing catalog; omit to create a new one'),
|
|
13212
13284
|
name: z.string().optional().describe('unique within the store, maximum 70 characters'),
|
|
@@ -13225,7 +13297,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13225
13297
|
title: 'Why a Microsoft ad or keyword was disapproved',
|
|
13226
13298
|
description: "WHY a Microsoft Advertising ad or keyword was DISAPPROVED: Microsoft's editorial reason codes, the word or phrase it objected to (term), where it appeared (location), the countries it applies to, and any appeal status, read live from Microsoft. Use it whenever a read-back shows editorialStatus Disapproved, or the user asks why a Bing ad is not running. Pass adGroupId plus adIds OR keywordIds (Microsoft will not mix ads and keywords in one request). The reply links Microsoft's table of what each code means. Free.",
|
|
13227
13299
|
inputSchema: {
|
|
13228
|
-
|
|
13300
|
+
...msAcct,
|
|
13229
13301
|
adGroupId: z.string().describe('the ad group the ads or keywords sit in'),
|
|
13230
13302
|
adIds: z.array(z.string()).optional().describe('the ads to explain'),
|
|
13231
13303
|
keywordIds: z.array(z.string()).optional().describe('or the keywords to explain (not both)'),
|
|
@@ -13236,11 +13308,110 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13236
13308
|
const d = await apiPost('/api/microsoft-ads/editorial-reasons', a);
|
|
13237
13309
|
return ok(d.note, d);
|
|
13238
13310
|
}));
|
|
13311
|
+
// ---------- Microsoft Advertising: negatives, bidding, conversion tracking, audiences, image + video ads (2026-10-27) ----------
|
|
13312
|
+
const msNegShape = z.array(z.object({ text: z.string().describe('≤100 characters'), matchType: z.enum(['Exact', 'Phrase']).optional().describe('default Phrase — Microsoft has no broad negative') }));
|
|
13313
|
+
server.registerTool('list_microsoft_ads_negative_keywords', {
|
|
13314
|
+
title: 'List Microsoft Advertising negative keywords',
|
|
13315
|
+
description: 'The NEGATIVE keywords (excluded searches) on a Microsoft Advertising campaign (campaignId) or ad group (adGroupId) with their ids and match types; with no id, the account’s SHARED negative keyword lists; with listId, the keywords in one list. Read-only, free.',
|
|
13316
|
+
inputSchema: { ...msAcct, campaignId: z.string().optional(), adGroupId: z.string().optional(), listId: z.string().optional().describe('a shared negative keyword list') },
|
|
13317
|
+
outputSchema: { ok: z.boolean().optional(), keywords: z.array(z.any()).optional(), lists: z.array(z.any()).optional(), sharedLists: z.array(z.string()).optional(), note: z.string().optional() },
|
|
13318
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
13319
|
+
}, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/negative-keywords/list', a); return ok(d.note, d); }));
|
|
13320
|
+
server.registerTool('add_microsoft_ads_negative_keywords', {
|
|
13321
|
+
title: 'Exclude searches on Microsoft Advertising (negative keywords)',
|
|
13322
|
+
description: 'EXCLUDE searches from a Microsoft Advertising campaign or ad group with negative keywords — Exact or Phrase only (Microsoft has no broad negative; default Phrase, which blocks every search containing the phrase), up to 20,000 per campaign or ad group. Broad-match keywords without negatives are the classic way to waste money: “stop paying for free” is this tool. It can only narrow spend, so it is never confirm-gated. Read back. Free.',
|
|
13323
|
+
inputSchema: { ...msAcct, campaignId: z.string().optional(), adGroupId: z.string().optional().describe('pass this OR campaignId'), keywords: msNegShape },
|
|
13324
|
+
outputSchema: { ok: z.boolean().optional(), added: z.array(z.any()).optional(), total: z.number().optional(), note: z.string().optional() },
|
|
13325
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
13326
|
+
}, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/negative-keywords', a); return ok(d.note, d); }));
|
|
13327
|
+
server.registerTool('remove_microsoft_ads_negative_keywords', {
|
|
13328
|
+
title: 'Remove Microsoft Advertising negative keywords',
|
|
13329
|
+
description: 'Remove negative keywords (by id, from list_microsoft_ads_negative_keywords) from a Microsoft Advertising campaign or ad group. On a LIVE campaign that lets those searches trigger paid ads again, so it needs confirm:true after the user agrees. Read back. Free.',
|
|
13330
|
+
inputSchema: { ...msAcct, campaignId: z.string().optional(), adGroupId: z.string().optional(), negativeKeywordIds: z.array(z.string()), confirm: z.boolean().optional().describe('REQUIRED true on a LIVE campaign') },
|
|
13331
|
+
outputSchema: { ok: z.boolean().optional(), removed: z.array(z.string()).optional(), stillThere: z.array(z.string()).optional(), note: z.string().optional() },
|
|
13332
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
13333
|
+
}, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/negative-keywords/remove', a); return ok(d.note, d); }));
|
|
13334
|
+
server.registerTool('manage_microsoft_ads_negative_keyword_list', {
|
|
13335
|
+
title: 'Manage a shared Microsoft negative keyword list',
|
|
13336
|
+
description: 'A SHARED negative keyword list on Microsoft Advertising — one set of exclusions reused across up to 20 campaigns. action: create (name + keywords; campaignIds attaches it straight away), add (keywords), remove (negativeKeywordIds), attach / detach (campaignIds), delete (permanent, confirm:true). Removing, detaching or deleting on a LIVE campaign lets those searches trigger paid ads again, so it needs confirm:true. Read back. Free.',
|
|
13337
|
+
inputSchema: { ...msAcct, action: z.enum(['create', 'add', 'remove', 'attach', 'detach', 'delete']), listId: z.string().optional().describe('every action but create'), name: z.string().optional().describe('create: ≤255 characters'), keywords: msNegShape.optional(), negativeKeywordIds: z.array(z.string()).optional(), campaignIds: z.array(z.string()).optional(), confirm: z.boolean().optional() },
|
|
13338
|
+
outputSchema: { ok: z.boolean().optional(), listId: z.string().optional(), keywords: z.array(z.any()).optional(), campaigns: z.array(z.string()).optional(), deleted: z.boolean().optional(), note: z.string().optional() },
|
|
13339
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
13340
|
+
}, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/negative-keyword-list', a); return ok(d.note, d); }));
|
|
13341
|
+
server.registerTool('set_microsoft_ads_bidding', {
|
|
13342
|
+
title: 'Change a Microsoft Advertising bid strategy',
|
|
13343
|
+
description: 'Change a Microsoft Advertising campaign’s BID STRATEGY, read back: EnhancedCpc, MaxClicks (maxCpc cap), MaxConversions (optional targetCpa), MaxConversionValue (optional targetRoas), TargetImpressionShare (targetImpressionShare + targetAdPosition). Search takes any; Audience EnhancedCpc / MaxConversions; Shopping EnhancedCpc; Performance Max MaxConversions / MaxConversionValue. TargetCpa and TargetRoas are sent as MaxConversions / MaxConversionValue with the target, which is what Microsoft converts them to since 2025-08-04. Conversion strategies need a UET tag and a conversion goal recording conversions. On a LIVE campaign it needs confirm:true. Free.',
|
|
13344
|
+
inputSchema: { ...msAcct, campaignId: z.string(), biddingScheme: z.enum(['EnhancedCpc', 'MaxClicks', 'MaxConversions', 'MaxConversionValue', 'TargetImpressionShare', 'TargetCpa', 'TargetRoas']), maxCpc: z.number().optional(), targetCpa: z.number().optional(), targetRoas: z.number().optional().describe('ratio, e.g. 4 = 400%'), targetImpressionShare: z.number().optional().describe('1–100'), targetAdPosition: z.enum(['FirstPage', 'MainLine', 'MainLine1']).optional(), confirm: z.boolean().optional().describe('REQUIRED true on a LIVE campaign') },
|
|
13345
|
+
outputSchema: { ok: z.boolean().optional(), campaignId: z.string().optional(), requested: z.string().optional(), bidding: z.any().optional(), note: z.string().optional() },
|
|
13346
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
13347
|
+
}, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/bidding', a); return ok(d.note, d); }));
|
|
13348
|
+
server.registerTool('list_microsoft_ads_uet_tags', {
|
|
13349
|
+
title: 'List Microsoft Advertising UET tags',
|
|
13350
|
+
description: 'The UET tags (Microsoft’s site tracking pixel) on this Microsoft Advertising customer: id, name, status (Active / Inactive / Unverified) and each tag’s install snippet. Read-only, free.',
|
|
13351
|
+
inputSchema: { ...msAcct, tagIds: z.array(z.string()).optional() },
|
|
13352
|
+
outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), tags: z.array(z.any()).optional(), note: z.string().optional() },
|
|
13353
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
13354
|
+
}, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/uet-tags', a); return ok(d.note, d); }));
|
|
13355
|
+
server.registerTool('create_microsoft_ads_uet_tag', {
|
|
13356
|
+
title: 'Create a Microsoft Advertising UET tag',
|
|
13357
|
+
description: 'Create a UET tag (Microsoft’s site tracking pixel) and get its install snippet, read back. Url / Event / Duration / PagesViewedPerVisit conversion goals, remarketing lists and conversion bidding all need one. Name ≤100 characters, unique per customer. Free.',
|
|
13358
|
+
inputSchema: { ...msAcct, name: z.string(), description: z.string().optional() },
|
|
13359
|
+
outputSchema: { ok: z.boolean().optional(), tagId: z.string().optional(), tag: z.any().optional(), note: z.string().optional() },
|
|
13360
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
13361
|
+
}, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/uet-tag', a); return ok(d.note, d); }));
|
|
13362
|
+
server.registerTool('create_microsoft_ads_conversion_goal', {
|
|
13363
|
+
title: 'Create a Microsoft Advertising conversion goal',
|
|
13364
|
+
description: 'Create a Microsoft Advertising CONVERSION GOAL, read back. type: Url (a thank-you page: urlExpression + urlOperator), Event (a UET custom event: eventAction / eventCategory / eventLabel / eventValue), OfflineConversion (conversions uploaded from a CRM with send_microsoft_ads_offline_conversions — no tag; Microsoft asks for 2 hours before the first upload), Duration (minimumDurationSeconds) or PagesViewedPerVisit (minimumPagesViewed). Every type but OfflineConversion needs tagId (create_microsoft_ads_uet_tag). category is required for Url / Event / Offline. Free.',
|
|
13365
|
+
inputSchema: {
|
|
13366
|
+
...msAcct, name: z.string().describe('≤100 characters, unique per customer'), type: z.enum(['Url', 'Event', 'OfflineConversion', 'Duration', 'PagesViewedPerVisit']), tagId: z.string().optional(),
|
|
13367
|
+
category: z.enum(['AddToCart', 'BeginCheckout', 'BookAppointment', 'Contact', 'Download', 'GetDirections', 'OutboundClick', 'PageView', 'Purchase', 'RequestQuote', 'Signup', 'SubmitLeadForm', 'Subscribe', 'Other']).optional(),
|
|
13368
|
+
urlExpression: z.string().optional(), urlOperator: z.enum(['Equals', 'BeginsWith', 'Contains', 'RegularExpression']).optional(),
|
|
13369
|
+
eventAction: z.string().optional(), eventCategory: z.string().optional(), eventLabel: z.string().optional(), eventValue: z.number().optional(),
|
|
13370
|
+
minimumDurationSeconds: z.number().optional(), minimumPagesViewed: z.number().optional(),
|
|
13371
|
+
revenueType: z.enum(['FixedValue', 'VariableValue', 'NoValue']).optional(), revenue: z.number().optional().describe('FixedValue: the value of one conversion; VariableValue: the default'), currencyCode: z.string().optional(),
|
|
13372
|
+
countType: z.enum(['All', 'Unique']).optional(), conversionWindowDays: z.number().optional().describe('1–90'), scope: z.enum(['Account', 'Customer']).optional(),
|
|
13373
|
+
},
|
|
13374
|
+
outputSchema: { ok: z.boolean().optional(), goalId: z.string().optional(), goal: z.any().optional(), note: z.string().optional() },
|
|
13375
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
13376
|
+
}, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/conversion-goal', a); return ok(d.note, d); }));
|
|
13377
|
+
server.registerTool('list_microsoft_ads_audience_targeting', {
|
|
13378
|
+
title: 'Audiences on a Microsoft campaign or ad group',
|
|
13379
|
+
description: 'The audiences (customer lists, remarketing, in-market, custom, similar, combined, product) targeted on — or EXCLUDED from — a Microsoft Advertising campaign or ad group, with each one’s bid adjustment and criterion id. Read-only, free.',
|
|
13380
|
+
inputSchema: { ...msAcct, campaignId: z.string().optional(), adGroupId: z.string().optional() },
|
|
13381
|
+
outputSchema: { ok: z.boolean().optional(), audiences: z.array(z.any()).optional(), unreadTypes: z.array(z.string()).optional(), note: z.string().optional() },
|
|
13382
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
13383
|
+
}, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/audience-targeting/list', a); return ok(d.note, d); }));
|
|
13384
|
+
server.registerTool('set_microsoft_ads_audience_targeting', {
|
|
13385
|
+
title: 'Target or exclude a Microsoft audience',
|
|
13386
|
+
description: 'Put an audience to work on a Microsoft Advertising campaign or ad group — e.g. the customer list from create_microsoft_ads_customer_list, to retarget it or EXCLUDE existing customers. action add (audienceId, exclude, bidAdjustment -90..900 %) or remove (criterionIds from list_microsoft_ads_audience_targeting). Microsoft allows biddable audiences at the campaign OR the ad-group level of one campaign, not both; exclusions at both; Performance Max can only exclude. Targeting or removing on a LIVE campaign needs confirm:true. Read back. Free.',
|
|
13387
|
+
inputSchema: { ...msAcct, campaignId: z.string().optional(), adGroupId: z.string().optional(), action: z.enum(['add', 'remove']).optional().describe('default add'), audienceId: z.string().optional().describe('list_microsoft_ads_audiences'), exclude: z.boolean().optional(), bidAdjustment: z.number().optional().describe('-90 to 900 (%); 0 = target without changing the bid'), criterionIds: z.array(z.string()).optional(), confirm: z.boolean().optional() },
|
|
13388
|
+
outputSchema: { ok: z.boolean().optional(), criterion: z.any().optional(), removed: z.array(z.string()).optional(), note: z.string().optional() },
|
|
13389
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
13390
|
+
}, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/audience-targeting', a); return ok(d.note, d); }));
|
|
13391
|
+
server.registerTool('create_microsoft_ads_image_ad', {
|
|
13392
|
+
title: 'Create a Microsoft image ad from a render (paused)',
|
|
13393
|
+
description: 'Turn a Hermoso render (or any picture) into a Microsoft Advertising IMAGE ad: a responsive ad on the Microsoft Audience Network (an Audience campaign) or a multimedia ad on Search. One imageUrl is centre-cropped to the 1.91:1 and 1:1 pictures Microsoft requires (or pass images{landscape, square, logo}), uploaded, and the ad is built PAUSED and read back with its pictures. businessName ≤25, 1–15 headlines ≤30, 1–5 longHeadlines ≤90, 1–5 descriptions ≤90. Search multimedia ads get a callToAction button (default LearnMore); Audience ads have none. videoIds attach videos from upload_microsoft_ads_video. Creating it Active in a live ad group needs confirm:true. Free.',
|
|
13394
|
+
inputSchema: {
|
|
13395
|
+
...msAcct, adGroupId: z.string(), businessName: z.string(), headlines: z.array(z.string()), longHeadlines: z.array(z.string()), descriptions: z.array(z.string()), finalUrls: z.array(z.string()),
|
|
13396
|
+
imageUrl: z.string().optional().describe('one picture, cut to both shapes for you'), images: z.object({ landscape: z.string().optional(), square: z.string().optional(), logo: z.string().optional() }).optional(), logoUrl: z.string().optional(),
|
|
13397
|
+
callToAction: z.string().optional().describe('Search multimedia ads only, e.g. LearnMore, ShopNow, SignUp'), videoIds: z.array(z.string()).optional(), videoSubType: z.enum(['VideoMedia16X9', 'VideoMedia5X4', 'VideoMedia1X1', 'VideoMedia4X5', 'VideoMedia9X16']).optional(),
|
|
13398
|
+
status: z.enum(['Active', 'Paused']).optional().describe('default Paused'), confirm: z.boolean().optional(),
|
|
13399
|
+
},
|
|
13400
|
+
outputSchema: { ok: z.boolean().optional(), adId: z.string().optional(), adGroupId: z.string().optional(), status: z.string().optional(), editorialStatus: z.string().optional(), cuts: z.array(z.any()).optional(), note: z.string().optional() },
|
|
13401
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
13402
|
+
}, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/image-ad', a); return ok(d.note, d); }));
|
|
13403
|
+
server.registerTool('upload_microsoft_ads_video', {
|
|
13404
|
+
title: 'Upload a video to Microsoft Advertising',
|
|
13405
|
+
description: 'Upload videos (a Hermoso render or any direct-download https file) to Microsoft Advertising, or check ones already uploaded with videoIds: Draft (still processing), Active, or Failed with Microsoft’s reason. Microsoft enables video per account and refuses clips under 6 seconds. Use the ids in create_microsoft_ads_image_ad(videoIds). Free.',
|
|
13406
|
+
inputSchema: { ...msAcct, urls: z.array(z.string()).optional(), videoIds: z.array(z.string()).optional(), description: z.string().optional() },
|
|
13407
|
+
outputSchema: { ok: z.boolean().optional(), videos: z.array(z.any()).optional(), note: z.string().optional() },
|
|
13408
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
13409
|
+
}, wrap(async (a) => { const d = await apiPost('/api/microsoft-ads/video', a); return ok(d.note, d); }));
|
|
13239
13410
|
server.registerTool('list_microsoft_ads_audiences', {
|
|
13240
13411
|
title: "List Microsoft Advertising audiences (Customer Match lists)",
|
|
13241
13412
|
description: "List a Microsoft Advertising account's audiences \u2014 by default the CUSTOMER LISTS (Customer Match), with each row's membership duration and its current Search and Audience-network sizes. TWO MICROSOFT CAVEATS THAT LOOK LIKE FAILURES AND ARE NOT: a size is nil or empty for UP TO 48 HOURS while a list is being built, and Microsoft will not use an audience of fewer than 300 people at all \u2014 so never report a fresh list's 0 as a failed upload. Pass types[] to ask for other audience types. Read-only, free, 0 credits.",
|
|
13242
13413
|
inputSchema: {
|
|
13243
|
-
|
|
13414
|
+
...msAcct,
|
|
13244
13415
|
audienceIds: z.array(z.string()).optional().describe('up to 100 specific ids \u2014 omit for all of the requested type'),
|
|
13245
13416
|
types: z.array(z.string()).optional().describe('audience types, default CustomerList'),
|
|
13246
13417
|
},
|
|
@@ -13254,7 +13425,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13254
13425
|
title: "Create an empty Microsoft Advertising Customer Match list",
|
|
13255
13426
|
description: "Create an EMPTY Customer Match list on a Microsoft Advertising account, ready for apply_microsoft_ads_customer_list to fill. membershipDuration is 1\u2013390 days, or -1 for no expiration (Microsoft's default is 30). scope 'Account' (the default) makes it usable only by that ad account; scope 'Customer' makes it usable by every account under the manager \u2014 Hermoso derives the right parent id for whichever you choose, because getting that pair wrong mis-parents the list silently. An audience is a definition: it cannot spend and it cannot serve on its own. Read back from Microsoft. 0 credits.",
|
|
13256
13427
|
inputSchema: {
|
|
13257
|
-
|
|
13428
|
+
...msAcct,
|
|
13258
13429
|
name: z.string().describe('maximum 128 characters'),
|
|
13259
13430
|
description: z.string().optional().describe('maximum 1024 characters'),
|
|
13260
13431
|
membershipDuration: z.number().optional().describe('1\u2013390 days, or -1 for no expiration. Microsoft\u2019s default is 30'),
|
|
@@ -13270,7 +13441,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13270
13441
|
title: "Upload customer emails into a Microsoft Customer Match list",
|
|
13271
13442
|
description: "Upload customer emails into a Microsoft Advertising Customer Match list so campaigns can target or exclude them. YOU PASS PLAIN EMAIL ADDRESSES \u2014 Hermoso normalizes and SHA-256 hashes them locally and sends ONLY the digests, so no plaintext ever leaves the server; values that arrive already hashed (64 hex characters) are passed through untouched. Microsoft's own normalization is applied exactly as published: trim, remove all dots from the user portion, remove any +alias, lowercase, then SHA-256. YOU MUST GET THE USER'S AGREEMENT FIRST: show them https://about.ads.microsoft.com/en-us/legal/customer-match-terms and only then call with acceptTerms:true, which Microsoft says 'eliminates the need to accept terms through the Microsoft Advertising UI' \u2014 without it nothing is uploaded and nothing is hashed. action is Add (additive, the default), Remove or Replace. Maximum 1000 items per call; page larger lists. Email is the only supported identifier \u2014 Microsoft publishes Phone and CRM as 'Not currently supported' and both are refused by name. DO NOT read a size of 0 afterwards as a failure: Microsoft leaves it empty for up to 48 hours while the audience builds. 0 credits.",
|
|
13272
13443
|
inputSchema: {
|
|
13273
|
-
|
|
13444
|
+
...msAcct,
|
|
13274
13445
|
audienceId: z.string().describe('the customer list to write into'),
|
|
13275
13446
|
emails: z.array(z.string()).optional().describe('PLAIN email addresses \u2014 normalized and SHA-256 hashed inside Hermoso, never sent as plaintext. Already-hashed 64-hex values pass through untouched. Maximum 1000 per call'),
|
|
13276
13447
|
action: z.enum(['Add', 'Remove', 'Replace']).optional().describe('Add (default) is additive across calls'),
|
|
@@ -13293,6 +13464,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13293
13464
|
const AL_CM_NOTE = ' Needs the Campaign Management key, which exists once AppLovin enables API access on the account; without it the answer says how to get it.';
|
|
13294
13465
|
const alIds = z.array(z.string()).optional();
|
|
13295
13466
|
const alOut = { ok: z.boolean().optional(), id: z.string().optional(), verified: z.boolean().optional(), note: z.string().optional() };
|
|
13467
|
+
// Per-creative-set brand override (the end card): AppLovin's BrandInfo object, all five fields required.
|
|
13468
|
+
const alBrand = {
|
|
13469
|
+
brandInfo: z.object({ title: z.string().describe('≤25 characters'), description: z.string().describe('≤75 characters'), buttonLabel: z.string().describe('CTA, ≤13 characters'), buttonColor: z.string().describe('"#RRGGBB"'), iconAssetId: z.string().describe('an uploaded IMG_ICON asset id') }).optional().describe('override the account brand on THIS creative set — how a brand A/B tests the end card'),
|
|
13470
|
+
useAccountBrandInfo: z.boolean().optional().describe('true = go back to the account-level brand info'),
|
|
13471
|
+
};
|
|
13296
13472
|
server.registerTool('list_applovin_ads_campaigns', {
|
|
13297
13473
|
title: 'List AppLovin Ads campaigns',
|
|
13298
13474
|
description: 'The brand’s AppLovin Ads campaigns: id, name, LIVE/PAUSED, daily budget, goal, countries, landing page. Rename or edit one with update_applovin_ads_campaign; pause or go live with set_applovin_ads_status. Read-only, free.' + AL_CM_NOTE,
|
|
@@ -13357,7 +13533,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13357
13533
|
server.registerTool('create_applovin_ads_creative_set', {
|
|
13358
13534
|
title: 'Create an AppLovin creative set (paused)',
|
|
13359
13535
|
description: 'Create an AppLovin creative set from uploaded asset ids, born PAUSED, optionally inside a campaign. It must hold EITHER an HTML playable OR a portrait image (IMG_INTER_P) plus a video; Hermoso checks that against AppLovin’s own asset types before creating it.' + AL_CM_NOTE,
|
|
13360
|
-
inputSchema: { name: z.string(), assetIds: z.array(z.string()).describe('AppLovin asset ids'), campaignId: z.string().optional(), countries: alIds, languages: alIds.describe('e.g. ENGLISH'), url: z.string().optional().describe('landing page for this set only (WEB)') },
|
|
13536
|
+
inputSchema: { name: z.string(), assetIds: z.array(z.string()).describe('AppLovin asset ids'), campaignId: z.string().optional(), countries: alIds, languages: alIds.describe('e.g. ENGLISH'), url: z.string().optional().describe('landing page for this set only (WEB)'), ...alBrand },
|
|
13361
13537
|
outputSchema: { ...alOut, creativeSet: z.any().optional(), pausedAfterCreate: z.boolean().optional() },
|
|
13362
13538
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
13363
13539
|
}, wrap(async (a) => { const d = await apiPost('/api/applovin-ads/creative-set', a); return ok(d.note, d); }));
|
|
@@ -13371,7 +13547,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13371
13547
|
server.registerTool('update_applovin_ads_creative_set', {
|
|
13372
13548
|
title: 'Edit an AppLovin creative set',
|
|
13373
13549
|
description: 'Edit an AppLovin creative set, read back: rename it, change its countries or languages (an empty list means all), its landing page (url; "" falls back to the campaign’s), replace its assets (assetIds, checked against AppLovin’s own asset types first), or add it to more campaigns (addToCampaignIds, up to 20). Adding a LIVE set to a LIVE campaign needs confirm:true. Status is set_applovin_ads_status.' + AL_CM_NOTE,
|
|
13374
|
-
inputSchema: { creativeSetId: z.string(), name: z.string().optional(), countries: alIds.describe('ISO codes; [] = all countries'), languages: alIds.describe('e.g. ENGLISH; [] = all'), url: z.string().optional().describe('landing page for this set; "" = the campaign’s'), assetIds: alIds.describe('REPLACES the set’s assets'), addToCampaignIds: alIds.describe('also run it in these campaigns (up to 20)'), confirm: z.boolean().optional().describe('required to add a LIVE set to a LIVE campaign') },
|
|
13550
|
+
inputSchema: { creativeSetId: z.string(), name: z.string().optional(), countries: alIds.describe('ISO codes; [] = all countries'), languages: alIds.describe('e.g. ENGLISH; [] = all'), url: z.string().optional().describe('landing page for this set; "" = the campaign’s'), assetIds: alIds.describe('REPLACES the set’s assets'), addToCampaignIds: alIds.describe('also run it in these campaigns (up to 20)'), ...alBrand, confirm: z.boolean().optional().describe('required to add a LIVE set to a LIVE campaign') },
|
|
13375
13551
|
outputSchema: { ...alOut, changed: z.array(z.string()).optional(), creativeSet: z.any().optional() },
|
|
13376
13552
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
13377
13553
|
}, wrap(async (a) => { const d = await apiPost('/api/applovin-ads/creative-set/update', a); return ok(d.note, d); }));
|
|
@@ -13382,6 +13558,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13382
13558
|
outputSchema: { ...alOut, sourceId: z.string().optional(), campaignId: z.string().optional(), version: z.string().optional(), creativeSet: z.any().optional(), pausedAfterCreate: z.boolean().optional() },
|
|
13383
13559
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
13384
13560
|
}, wrap(async (a) => { const d = await apiPost('/api/applovin-ads/creative-set/clone', a); return ok(d.note, d); }));
|
|
13561
|
+
server.registerTool('applovin_ads_change_history', {
|
|
13562
|
+
title: 'AppLovin campaign change history',
|
|
13563
|
+
description: 'Who changed what on the brand’s AppLovin campaigns, and when: creation, edits (field by field, before and after), status changes and ARCHIVING — including archived campaigns that list_applovin_ads_campaigns no longer returns. One campaign (campaignId) or the whole account; since/until must fall within the latest 30 UTC days and go together (neither = the last 7 days). Read-only, free.' + AL_CM_NOTE,
|
|
13564
|
+
inputSchema: { campaignId: z.string().optional().describe('ONE campaign id; omit for every campaign'), since: z.string().optional().describe('YYYY-MM-DD UTC, with until'), until: z.string().optional().describe('YYYY-MM-DD UTC, with since'), page: z.number().optional().describe('from 1'), size: z.number().optional().describe('1–100, default 100') },
|
|
13565
|
+
outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), page: z.number().optional(), changes: z.array(z.any()).optional(), note: z.string().optional() },
|
|
13566
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
13567
|
+
}, wrap(async (a) => { const d = await apiPost('/api/applovin-ads/change-history', a); return ok(d.note, d); }));
|
|
13385
13568
|
server.registerTool('list_applovin_ads_catalogs', {
|
|
13386
13569
|
title: 'List AppLovin catalogs',
|
|
13387
13570
|
description: 'The AppLovin account’s product catalogs and their variant sets: the catalogId and variantSetId a dynamic-ads campaign (create_applovin_ads_campaign) needs. Website accounts. Read-only, free.' + AL_CM_NOTE,
|
|
@@ -13413,7 +13596,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13413
13596
|
since: z.string().optional().describe('YYYY-MM-DD, default 7 days ago'), until: z.string().optional().describe('YYYY-MM-DD, default today'),
|
|
13414
13597
|
columns: alIds.describe('e.g. day, campaign, campaign_id_external, creative_set, country, impressions, clicks, ctr, cost, sales, roas_7d, chka_usd_7d'),
|
|
13415
13598
|
attribution: z.enum(['click', 'click_and_view']).optional().describe('web only, default click'), filters: z.record(z.array(z.string())).optional().describe('column -> values'),
|
|
13416
|
-
cohort: z.boolean().optional().describe('attribute to impression day'), notZero: z.boolean().optional().describe('drop rows whose metrics are all zero'), limit: z.number().optional().describe('1–10000, default 1000'), offset: z.number().optional(),
|
|
13599
|
+
cohort: z.boolean().optional().describe('attribute to impression day'), notZero: z.boolean().optional().describe('drop rows whose metrics are all zero'), having: z.string().optional().describe('server-side numeric filter, e.g. "cost > 0 AND roas_7d < 1": report columns, numbers, > < >= <= = !=, AND, OR, parentheses (AppLovin warns it slows the report)'), limit: z.number().optional().describe('1–10000, default 1000'), offset: z.number().optional(),
|
|
13417
13600
|
},
|
|
13418
13601
|
outputSchema: { ok: z.boolean().optional(), type: z.string().optional(), since: z.string().optional(), until: z.string().optional(), columns: z.array(z.string()).optional(), count: z.number().optional(), rows: z.array(z.any()).optional(), totals: z.any().optional(), note: z.string().optional() },
|
|
13419
13602
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -13450,6 +13633,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13450
13633
|
targetUrl: z.string().describe('the landing page (must not block OAI-AdsBot / OAI-SearchBot in robots.txt)'),
|
|
13451
13634
|
imageUrl: z.string().optional().describe('public https URL of a STILL image — every text ad needs imageUrl OR fileId (ChatGPT Ads refuses an ad with no creative.file_id unless the campaign is a product feed; measured 2026-09-06); a video URL is refused, this channel has no video format'),
|
|
13452
13635
|
fileId: z.string().optional().describe('reuse an image ChatGPT Ads already holds: the fileId list_openai_ads_campaigns returns for an existing ad (list with adGroupId). Wins over imageUrl; use it to copy an ad exactly'),
|
|
13636
|
+
imageUrls: z.array(z.string()).optional().describe('4–6 public https URLs of SQUARE stills, in display order, for an ad with MULTIPLE images (one title, body and landing page shared by all). OpenAI enables this per ad account, and with automatic bidding only if the account is also enabled for that — otherwise use a fixed bid. Not with imageUrl/fileId or price'),
|
|
13637
|
+
fileIds: z.array(z.string()).optional().describe('the same as imageUrls with images already on the account (4–6 file ids); may be mixed with imageUrls, ids first. On an edit, omit both to keep the ad\u2019s current images; a new list replaces them all'),
|
|
13453
13638
|
price: z.string().optional().describe('optional price string shown on the card'),
|
|
13454
13639
|
});
|
|
13455
13640
|
server.registerTool('list_openai_ads_campaigns', {
|
|
@@ -13574,7 +13759,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13574
13759
|
title: 'Export a Microsoft Advertising account as a bulk file',
|
|
13575
13760
|
description: 'Export a Microsoft Advertising account as a BULK FILE — one spreadsheet holding whatever slice of the account you ask for. This is the ONLY way to read most of a Microsoft account: roughly 185 of Microsoft’s record types are reachable here and nowhere else in Hermoso — sitelink / callout / structured-snippet / image / logo / price / promotion ad extensions, labels, shared negative keyword lists, bid strategies, audiences and their associations, experiments, seasonality adjustments, conversion goals, asset groups and listing groups, feeds. Pass entities[] (default Campaigns, AdGroups, Ads, Keywords), campaignIds[] to narrow it, or since for a DELTA of what changed (Microsoft refuses anything older than 30 days). The reply carries the WHOLE FILE as a string in `file` — edit that and pass it straight to microsoft_ads_bulk_upload, which is the round trip this exists for. A bulk export is a SNAPSHOT of what Microsoft held when it ran, never a live view, so do not quote a status from it as current after a change. Microsoft builds it asynchronously: a large account can come back pending:true with a downloadRequestId — call again with that id rather than resubmitting, which would export the whole account a second time. Read-only, 0 credits.',
|
|
13576
13761
|
inputSchema: {
|
|
13577
|
-
|
|
13762
|
+
...msAcct,
|
|
13578
13763
|
entities: z.array(z.string()).optional().describe('Microsoft DownloadEntity names — Campaigns, AdGroups, Ads, Keywords (the default), plus SitelinkAdExtensions, CalloutAdExtensions, Labels, Budgets, BidStrategies, AssetGroups, ConversionGoal, Experiments, Audiences and ~180 more. An unknown one is refused BY NAME, because Microsoft answers it with a deserialization error that names no field and reads like an outage'),
|
|
13579
13764
|
campaignIds: z.array(z.string()).optional().describe('export only these campaigns (max 1000) instead of the whole account'),
|
|
13580
13765
|
since: z.string().optional().describe('ISO date — export only what CHANGED since then. Microsoft refuses a delta older than 30 days, and refuses one at all alongside QualityScoreData / BidSuggestionsData'),
|
|
@@ -13596,7 +13781,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13596
13781
|
title: 'Apply a Microsoft Advertising bulk file (spend-gated)',
|
|
13597
13782
|
description: 'Apply a Microsoft Advertising BULK FILE — edit hundreds of campaigns, ad groups, ads, keywords, extensions or labels in one request. Pass `file` as the CSV/TSV string; microsoft_ads_bulk_download returns exactly that shape in its `file` field. THE SPEND GATE, AND IT IS UNLIKE EVERY OTHER TOOL HERE. A bulk file carries a Status column, so one upload can turn campaigns ON — and confirm:true ALONE IS NOT ENOUGH for a file: it proves you meant to upload something, not that you know what THIS file does, and it reads identically for a file that changes two bids and one that activates fifty campaigns. So: call it ONCE with no confirmation. Nothing is uploaded, and you are told exactly how many objects the file would ACTIVATE (they can then spend real money) and DELETE (which Microsoft never undoes — a deleted object is never returned by any read again), each named with its row number, type, id and name. SHOW THAT LIST TO THE USER, get an explicit yes, then call again with confirm:true, confirmActivations:<n> and confirmDeletions:<n>. If the activations are incidental — a file you round-tripped out of a download and only meant to edit bids in — pass pauseInstead:true instead and those rows are written as Paused, so everything else lands with nothing armed; it is refused when the file also deletes, because pausing is a safe substitute for activating and there is none for deleting. Partial success is normal on this API: rows Microsoft rejects come back listed individually and the correct ones ARE applied, so never describe a CompletedWithErrors upload as a failure. 0 credits.',
|
|
13598
13783
|
inputSchema: {
|
|
13599
|
-
|
|
13784
|
+
...msAcct,
|
|
13600
13785
|
file: z.string().describe('the bulk file itself, CSV or TSV, whose first column header must be "Type"'),
|
|
13601
13786
|
fileType: z.enum(['Csv', 'Tsv']).optional().describe('inferred from the file when omitted'),
|
|
13602
13787
|
confirm: z.boolean().optional().describe('the user has SEEN what this file activates and deletes and said yes. Not sufficient on its own — the two echo counts below are also required'),
|
|
@@ -13618,7 +13803,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13618
13803
|
title: 'Microsoft Advertising conversion goals',
|
|
13619
13804
|
description: 'The conversion goals on a Microsoft Advertising account — what the account COUNTS as a conversion, and therefore what every conversion-optimised bid strategy on it is optimising toward. Flags which ones are OFFLINE goals: those are the only goals send_microsoft_ads_offline_conversions can post to, and its conversionName must match one of their names EXACTLY, including case and spacing. An account with no goals at all has no conversion tracking — say that plainly rather than reporting an empty list, because it means smart bidding there has nothing to aim at. Read-only, 0 credits.',
|
|
13620
13805
|
inputSchema: {
|
|
13621
|
-
|
|
13806
|
+
...msAcct,
|
|
13622
13807
|
goalIds: z.array(z.string()).optional().describe('specific goal ids — omit for all of them'),
|
|
13623
13808
|
goalType: z.string().optional().describe('filter to one Microsoft ConversionGoalType, e.g. OfflineConversion'),
|
|
13624
13809
|
},
|
|
@@ -13632,7 +13817,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13632
13817
|
title: 'Send offline + enhanced conversions to Microsoft',
|
|
13633
13818
|
description: 'Tell Microsoft Advertising about conversions that happened OFF the website — a phone sale, an in-store purchase, a lead that closed weeks after the click — so smart bidding on that account stops optimising against only what converted online. Each row needs conversionName (an existing OFFLINE conversion goal, matched by name — list_microsoft_ads_conversion_goals shows them), conversionTime (within the last 90 days AND after the click), and an identifier: either microsoftClickId (the msclkid Microsoft appends to the landing-page URL) or, for ENHANCED conversions, a plain email and/or phone. YOU PASS PLAIN VALUES AND THE HASHING HAPPENS SERVER-SIDE: emails are normalized and SHA-256’d to Microsoft’s own published five-step spec, which is verified against Microsoft’s own worked digests. A PHONE MUST ALREADY CARRY ITS COUNTRY CODE (+14255551234) and is refused otherwise — Microsoft’s spec says "normalize to E.164 format with country code" and stops there, and guessing which country a bare 4255551234 belongs to is not a mistake you would ever see: the upload succeeds and matches nobody. SAY THIS WHEN REPORTING: accepted is not the same as visible. Microsoft takes up to 6 hours to show offline conversion data and applies nothing at all in the first 2 hours after a goal is created, and a conversion only counts inside the goal’s own conversion window. Duplicates are silently ignored (the first wins), so re-sending a batch is safe and changes nothing. 0 credits.',
|
|
13634
13819
|
inputSchema: {
|
|
13635
|
-
|
|
13820
|
+
...msAcct,
|
|
13636
13821
|
conversionName: z.string().optional().describe('default goal name for every row that does not carry its own'),
|
|
13637
13822
|
conversions: z.array(z.object({
|
|
13638
13823
|
conversionName: z.string().optional().describe('must match an existing Microsoft OFFLINE conversion goal exactly'),
|
|
@@ -13656,7 +13841,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13656
13841
|
title: 'Who else is bidding on the same Microsoft auctions',
|
|
13657
13842
|
description: 'Who ELSE is competing for the same Microsoft Advertising auctions — the other advertisers’ domains with their impression share, overlap rate, position-above rate, top-of-page rate and outranking share. microsoft_ads_report tells you what the account did; this tells you who it did it against, which is the input to a bid or a creative decision rather than a report. Scope it with entityType Account (default, the whole account) / Campaign / AdGroup / Keyword plus entityIds (max 200), and optionally segment by up to three of Day, DayOfWeek, Device. SAY THIS WHEN REPORTING: every figure is a share of the auctions YOUR ads entered, never a measure of a competitor’s whole account or budget — a competitor missing from the list did not necessarily not bid. An empty result means there was no delivery to compare in that window, which is MISSING data; never report it as "nobody is bidding against you". Read-only, 0 credits.',
|
|
13658
13843
|
inputSchema: {
|
|
13659
|
-
|
|
13844
|
+
...msAcct,
|
|
13660
13845
|
entityType: z.enum(['Account', 'Campaign', 'AdGroup', 'Keyword']).optional().describe('default Account'),
|
|
13661
13846
|
entityIds: z.array(z.string()).optional().describe('REQUIRED for Campaign / AdGroup / Keyword, max 200. Microsoft only defaults to the whole account for entityType Account'),
|
|
13662
13847
|
since: z.string().optional().describe('YYYY-MM-DD, default 30 days back'),
|
|
@@ -13683,7 +13868,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13683
13868
|
title: 'What Microsoft suggests changing on the account',
|
|
13684
13869
|
description: 'What Microsoft Advertising ITSELF suggests changing on the account: budget raises, new keywords, broad-match widenings, conflicting negative keywords to remove, and responsive search ads or extra headlines Microsoft has written. Each row carries the campaign and ad group it touches plus Microsoft’s OWN estimate of what applying it would add to clicks, impressions, conversions and COST; a budget recommendation states the current and the recommended daily amount, so the money is a vendor fact rather than a guess. SAY THIS WHEN REPORTING: every Microsoft recommendation INCREASES what the account buys, that is what they are for, so none of them is a free win, and an empty list means Microsoft has no ADVICE (usually too little delivery to compute any), never that the account is optimal. Dismissed recommendations are hidden unless you ask for them. Read-only, 0 credits.',
|
|
13685
13870
|
inputSchema: {
|
|
13686
|
-
|
|
13871
|
+
...msAcct,
|
|
13687
13872
|
types: z.array(z.string()).optional().describe('narrow to some of ADD_BROAD_MATCH_KEYWORD, CAMPAIGN_BUDGET, KEYWORD, REMOVE_CONFLICTING_NEGATIVE_KEYWORD, RESPONSIVE_SEARCH_AD, RESPONSIVE_SEARCH_AD_ASSET, all six by default. An unknown name is refused BY NAME, because Microsoft answers one with a deserialization error that names no field'),
|
|
13688
13873
|
limit: z.number().optional().describe('Microsoft’s MaxCount, omit for everything'),
|
|
13689
13874
|
includeDismissed: z.boolean().optional().describe('also show recommendations already dismissed'),
|
|
@@ -13698,7 +13883,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13698
13883
|
title: 'Apply Microsoft recommendations (spend-gated)',
|
|
13699
13884
|
description: 'Apply Microsoft Advertising recommendations by id. THIS SPENDS REAL MONEY AND HAS NO UNDO. It raises daily budgets, adds keywords, widens keywords to broad match, DELETES negative keywords so the account buys more searches, and publishes Microsoft-written ads under the brand’s name, all live at the next auction. THE GATE IS UNLIKE THE OTHER MICROSOFT SPEND SWITCHES, AND confirm:true ALONE IS REFUSED: it proves you meant to apply something, not that you know what THESE ones do, and it reads identically for one keyword suggestion and forty budget raises. So call it ONCE with no confirmation. Nothing is applied, and you get every recommendation named with its campaign, exactly what it changes, the current -> recommended budget where there is one, and Microsoft’s own cost estimate. SHOW THAT LIST TO THE USER, get an explicit yes, then call again with confirm:true, confirmCount:<n> and confirmCostIncrease:<n>. BOTH NUMBERS ARE RECOMPUTED FROM A FRESH READ at that moment, so a recommendation Microsoft has withdrawn, already applied or re-priced since you looked fails the check instead of being applied unseen. If ANY id you name is no longer on offer, NOTHING is applied, not even the ones that still are, because applying part of a set you inspected whole is not what you asked for. Microsoft can reject individual items and apply the rest, so the result reports per item: never call a partial apply a failure, and never call it a success. 0 credits to us; the spend lands on the ad account.',
|
|
13700
13885
|
inputSchema: {
|
|
13701
|
-
|
|
13886
|
+
...msAcct,
|
|
13702
13887
|
recommendationIds: z.array(z.string()).describe('ids from microsoft_ads_recommendations, max 100. There is deliberately no "apply everything"'),
|
|
13703
13888
|
confirm: z.boolean().optional().describe('the user has SEEN the pre-flight list and said yes. Not sufficient on its own, both echo numbers below are also required'),
|
|
13704
13889
|
confirmCount: z.number().optional().describe('echo back how many recommendations this applies: the exact count the un-confirmed call reported'),
|
|
@@ -13712,7 +13897,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13712
13897
|
title: 'Dismiss Microsoft recommendations',
|
|
13713
13898
|
description: 'Dismiss Microsoft Advertising recommendations by id so Microsoft stops offering them. IT CANNOT SPEND and it changes nothing about what the account runs (it takes advice off the list), so it takes no confirmation at all, which is the point: reach for this rather than applying something just to clear it. Nothing is destroyed either: Microsoft still returns a dismissed recommendation from a read, flagged as dismissed, and microsoft_ads_recommendations will show it again with includeDismissed. Ids Microsoft has already withdrawn are reported and are not an error, they are already off the list, which is the outcome you wanted. 0 credits.',
|
|
13714
13899
|
inputSchema: {
|
|
13715
|
-
|
|
13900
|
+
...msAcct,
|
|
13716
13901
|
recommendationIds: z.array(z.string()).describe('ids from microsoft_ads_recommendations, max 100'),
|
|
13717
13902
|
},
|
|
13718
13903
|
outputSchema: { ok: z.boolean().optional(), accountId: z.string().optional(), dismissed: z.number().optional(), rejected: z.array(z.string()).optional(), missing: z.array(z.string()).optional(), note: z.string().optional() },
|
|
@@ -13722,7 +13907,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13722
13907
|
title: 'Is Microsoft changing this ad account by itself?',
|
|
13723
13908
|
description: 'Whether Microsoft is allowed to change this ad account BY ITSELF, unattended: the auto-apply opt-in status for each of the five recommendation types Microsoft supports. THIS IS THE READ THAT ANSWERS "is something changing my account when nobody is looking?", and it is worth running on any Microsoft account you inherit: an account can already be opted in without anyone at the brand having done it, and there is no other way to find out from here. A type Microsoft returns no status for is reported as UNKNOWN, never as off. TWO THINGS THAT CATCH PEOPLE OUT AND ARE STATED IN THE REPLY: these five type names are a DIFFERENT, case-sensitive vocabulary from the six microsoft_ads_recommendations uses (Microsoft cannot auto-apply a budget recommendation at all, there is no such type), and three of the five have no readable recommendation, so there is no way to preview what auto-apply would do for those. Read-only, 0 credits.',
|
|
13724
13909
|
inputSchema: {
|
|
13725
|
-
|
|
13910
|
+
...msAcct,
|
|
13726
13911
|
types: z.array(z.string()).optional().describe('narrow to some of ResponsiveSearchAdsOpportunity, MultiMediaAdsOpportunity, RemoveConflictingNegativeKeywordOpportunity, FixConversionGoalSettingsOpportunity, CreateConversionGoalOpportunity, all five by default'),
|
|
13727
13912
|
},
|
|
13728
13913
|
outputSchema: { ok: z.boolean().optional(), accountId: z.string().optional(), status: z.record(z.any()).optional(), on: z.array(z.string()).optional(), off: z.array(z.string()).optional(), unread: z.array(z.string()).optional(), note: z.string().optional() },
|
|
@@ -13732,7 +13917,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
13732
13917
|
title: 'Let Microsoft change the account by itself, or stop it (per type)',
|
|
13733
13918
|
description: 'Switch Microsoft’s auto-apply on or off, per recommendation type. SWITCHING ONE ON IS UNLIKE EVERY OTHER WRITE IN HERMOSO: it is STANDING PERMISSION FOR MICROSOFT TO CHANGE THIS AD ACCOUNT ON ITS OWN, indefinitely, with nobody watching, publishing search and multimedia ads it wrote under the brand’s name, DELETING negative keywords so the account starts buying searches it currently blocks, or creating and changing conversion goals so every smart-bidding strategy on the account re-aims. It has no expiry and no schedule you can inspect, and nothing in Hermoso will ever turn it off for the user. AND THERE IS NOTHING TO PREVIEW: Microsoft decides later, on evidence that does not exist yet, so no count, no dry run and no list can tell you what it will do, which is exactly why a blast-radius echo would be meaningless here and each type has to be named instead. To turn any type ON: confirm:true AND confirmTypes listing every type being turned on, individually. There is no "all" and no default; naming four of five leaves the fifth refused BY NAME. TURNING IT OFF IS NEVER GATED, set those types to false and it goes straight through, because that only ever reduces what Microsoft does unattended. A type already in the state you asked for is not sent at all. THE ANSWER IS THE READ-BACK: the result re-reads Microsoft and says done / not done / could not tell PER TYPE, and you report that, never the fact that the call returned. 0 credits.',
|
|
13734
13919
|
inputSchema: {
|
|
13735
|
-
|
|
13920
|
+
...msAcct,
|
|
13736
13921
|
optIns: z.record(z.boolean()).describe('map of Microsoft auto-apply type name to true/false, e.g. {"RemoveConflictingNegativeKeywordOpportunity": false}. Case-sensitive keys: ResponsiveSearchAdsOpportunity, MultiMediaAdsOpportunity, RemoveConflictingNegativeKeywordOpportunity, FixConversionGoalSettingsOpportunity, CreateConversionGoalOpportunity'),
|
|
13737
13922
|
confirm: z.boolean().optional().describe('required to turn ANY type ON, after the user has been told what Microsoft will be allowed to do unattended. Never needed to turn one off'),
|
|
13738
13923
|
confirmTypes: z.array(z.string()).optional().describe('every type you are turning ON, named individually. That is the echo which proves you know WHICH permission you are granting, where a count could not'),
|
|
@@ -14246,6 +14431,19 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14246
14431
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
14247
14432
|
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/insights/search-terms', a); return ok(`${d.count} search-term row(s):\n${d.note}`, d); }));
|
|
14248
14433
|
|
|
14434
|
+
server.registerTool('apple_ads_rejection_reasons', {
|
|
14435
|
+
title: 'Why an Apple Ads creative or brand was rejected',
|
|
14436
|
+
description: 'WHY an Apple Ads creative, custom product page or Maps brand was REJECTED: Apple’s reason code, type and level (default or custom product page, or a locale of one), the asset or product page it applies to, the country and language, and Apple’s comment — read live. Use it whenever list_apple_ads_creatives shows systemStatus INVALID, or the user asks why an Apple ad is not running. Pass adamId for an app’s reasons, mapsBrandId for a Maps brand’s policy reasons, or rejectionReasonId for one reason. Read-only, free.',
|
|
14437
|
+
inputSchema: {
|
|
14438
|
+
adamId: z.string().optional().describe('the app’s App Store id — every reason Apple holds for it'),
|
|
14439
|
+
mapsBrandId: z.string().optional().describe('a Maps brand id (list_apple_ads_brands) — its policy rejection reasons. Not `brandId`: over the REST API that name means a Hermoso brand'),
|
|
14440
|
+
rejectionReasonId: z.string().optional().describe('one reason by id'),
|
|
14441
|
+
limit: z.number().optional(), offset: z.number().optional(),
|
|
14442
|
+
},
|
|
14443
|
+
outputSchema: { ok: z.boolean().optional(), kind: z.string().optional(), count: z.number().optional(), total: z.number().nullable().optional(), reasons: z.array(z.any()).optional(), note: z.string().optional() },
|
|
14444
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
14445
|
+
}, wrap(async (a) => { const d = await apiPost('/api/apple-ads/rejection-reasons', a); return ok(d.note, d); }));
|
|
14446
|
+
|
|
14249
14447
|
server.registerTool('apple_ads_change_history', {
|
|
14250
14448
|
title: 'Apple Ads change history',
|
|
14251
14449
|
description: 'Who changed what on this Apple Ads account, and when. Call it with start and/or end to get one row per TRANSACTION GROUP — a single user action that may have touched several fields, which is what changeCount counts — with the user, the entity and the event type (CREATE / UPDATE / DELETE). Then pass a row’s detailId back as detailId to get the field-by-field before and after. THE TIME BOUND IS MANDATORY: Apple answers 400 without one, so pass start (meaning "since then"), end ("up to then"), or both. Narrow with campaignId, adGroupId, entityType, eventType or userId. Useful for "why did this campaign’s budget change overnight" and for reconstructing what an agent did on the account. On a CREATE event the old values are empty by design; a DELETE is implemented as a record update so its old values ARE populated. Read-only, free.',
|
|
@@ -19365,7 +19563,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
19365
19563
|
}));
|
|
19366
19564
|
server.registerTool('create_snapchat_ads_lead_form', {
|
|
19367
19565
|
title: 'Create a Snapchat lead form',
|
|
19368
|
-
description: "Create the in-app form a Snapchat lead ad opens. Snapchat's rules are refused BY NAME, never repaired: FIRST_NAME and LAST_NAME are required, plus at least one of EMAIL or PHONE_NUMBER; ADDRESS and POSTAL_CODE cannot both be on one form; title max 25 characters, description max 180, legal title 35 / description 80, at most 2 consent checkboxes, only one question may use preferred options. A form spends nothing; it is read back from Snapchat. Snapchat has NO endpoint that lists leads afterwards — to receive them, turn on delivery with manage_snapchat_ads_lead_webhook(action:'subscribe'). Free.",
|
|
19566
|
+
description: "Create the in-app form a Snapchat lead ad opens. Snapchat's rules are refused BY NAME, never repaired: FIRST_NAME and LAST_NAME are required, plus at least one of EMAIL or PHONE_NUMBER; ADDRESS and POSTAL_CODE cannot both be on one form; title max 25 characters, description max 180, legal title 35 / description 80, at most 2 consent checkboxes, only one question may use preferred options, and an end page (endPage) is required. A form spends nothing; it is read back from Snapchat. Snapchat has NO endpoint that lists leads afterwards — to receive them, turn on delivery with manage_snapchat_ads_lead_webhook(action:'subscribe'). Free.",
|
|
19369
19567
|
inputSchema: {
|
|
19370
19568
|
adAccountId: z.string().optional(),
|
|
19371
19569
|
name: z.string().describe('the form name in Ads Manager'),
|
|
@@ -19375,7 +19573,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
19375
19573
|
privacyPolicyUrl: z.string().optional().describe('linked on the confirmation screen'),
|
|
19376
19574
|
legal: z.object({ title: z.string(), description: z.string(), consents: z.array(z.union([z.string(), z.object({ text: z.string(), required: z.boolean().optional() })])).optional() }).optional().describe('legal disclosure: title max 35, description max 80, up to 2 consent checkboxes'),
|
|
19377
19575
|
bannerMediaId: z.string().optional().describe('an IMAGE media id already on the ad account; 75:23, 750x230 to 1875x575, 5MB'),
|
|
19378
|
-
endPage: z.object({ callToAction: z.enum(['VIEW_WEBSITE', 'BOOK_NOW', 'LEARN_MORE', 'DONATE', 'SPECIAL_OFFER', 'SCHEDULE_NOW', 'BUY_TICKETS', 'TEST_DRIVE', 'APPLY_NOW', 'GET_COUPON', 'CLAIM_SAMPLE', 'FREE_TRIAL']),
|
|
19576
|
+
endPage: z.object({ url: z.string().describe('where its button goes'), callToAction: z.enum(['VIEW_WEBSITE', 'BOOK_NOW', 'LEARN_MORE', 'DONATE', 'SPECIAL_OFFER', 'SCHEDULE_NOW', 'BUY_TICKETS', 'TEST_DRIVE', 'APPLY_NOW', 'GET_COUPON', 'CLAIM_SAMPLE', 'FREE_TRIAL']).optional().describe('default VIEW_WEBSITE — the end page has its own list, not the creative one'), headline: z.string().optional().describe('defaults to the form title'), description: z.string().optional().describe('defaults to the form description') }).describe('REQUIRED by Snapchat (E25022): the screen shown after someone submits'),
|
|
19379
19577
|
},
|
|
19380
19578
|
outputSchema: { adAccountId: z.string().optional(), formId: z.string().optional(), form: z.any().optional(), verified: z.boolean().optional(), note: z.string().optional() },
|
|
19381
19579
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
@@ -20440,6 +20638,92 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
20440
20638
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
20441
20639
|
}, wrap(async (a) => { const d = await apiPost('/api/notion/schedule', a); return ok(d.note, d); }));
|
|
20442
20640
|
|
|
20641
|
+
// DATABASE ROWS (2026-10-27): read a Notion database the team keeps (a content calendar) and change one row's
|
|
20642
|
+
// properties (mark it Posted, put the live link in). Rules + citations: lib/notion.mjs. Free.
|
|
20643
|
+
server.group('files');
|
|
20644
|
+
server.registerTool('query_notion_database', {
|
|
20645
|
+
title: 'Read a Notion database',
|
|
20646
|
+
description: "Read the ROWS of a Notion database the user shared with Hermoso — e.g. the team's content calendar: each row's title, id, link and every property as a plain value (status, date, channels, caption, links), plus the database's columns with their types and options. Filter with match {\"Status\": \"Scheduled\"} (typed from the database's own columns) or a raw Notion filter object; sort with sortBy (a column, or created_time / last_edited_time) and direction. Up to 100 rows per call, cursor for more; the answer says when Notion stopped at its 10,000-result limit. Use it to import a calendar and schedule its posts, or to find the row update_notion_row changes. Read-only, free. Needs Notion connected (Settings > Connectors > Notion).",
|
|
20647
|
+
inputSchema: { databaseId: z.string().optional().describe('database id or link (list_notion_pages type:"database")'), dataSourceId: z.string().optional(), match: z.record(z.any()).optional().describe('{"Column": value} — equality for status/select/checkbox/number/date, contains for text/multi-select'), filter: z.record(z.any()).optional().describe('a raw Notion filter object'), sortBy: z.string().optional(), direction: z.enum(['ascending', 'descending']).optional(), limit: z.number().optional().describe('1-100, default 50'), cursor: z.string().optional(), archived: z.boolean().optional().describe('read archived rows instead') },
|
|
20648
|
+
outputSchema: { ok: z.boolean().optional(), database: z.string().optional(), dataSourceId: z.string().optional(), schema: z.any().optional(), count: z.number().optional(), rows: z.array(z.any()).optional(), nextCursor: z.string().nullable().optional(), incomplete: z.boolean().optional(), note: z.string().optional() },
|
|
20649
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
20650
|
+
}, wrap(async (a) => {
|
|
20651
|
+
const d = await apiPost('/api/notion/query', a);
|
|
20652
|
+
const cols = Object.entries(d.schema || {}).map(([k, v]) => `${k} (${v.type}${v.options ? `: ${v.options.join(' / ')}` : ''})`).join(', ');
|
|
20653
|
+
const show = (v) => (Array.isArray(v) ? v.join(', ') : v);
|
|
20654
|
+
return ok(`${d.note}\nColumns: ${cols}${d.count ? `\n${d.rows.map((r) => `- ${r.title} [${r.id}] ${Object.entries(r.properties).filter(([, v]) => v !== null && v !== '' && !(Array.isArray(v) && !v.length)).map(([k, v]) => `${k}: ${show(v)}`).join(' | ')}`).join('\n')}` : ''}${d.nextCursor ? `\n(more: cursor=${d.nextCursor})` : ''}`, d);
|
|
20655
|
+
}));
|
|
20656
|
+
server.registerTool('update_notion_row', {
|
|
20657
|
+
title: 'Update a Notion database row',
|
|
20658
|
+
description: "Change properties on ONE row of a Notion database (pageId from query_notion_database) — e.g. mark a calendar row Status \"Posted\" and put the live post link in its URL column. properties is {\"Column\": value} by column name: text, number, checkbox, select (an existing option or a new one), status (must be an existing option), multi-select (array; replaces the whole value), date (ISO, or {start, end}), url, email, phone; null clears. Validated against the database's own columns before anything is sent and refused by name otherwise, never coerced; columns you do not name keep their values. Read back from Notion. trash:true moves the row to Notion's trash (restorable; returns a preview first and needs confirm:true), trash:false restores it. Free.",
|
|
20659
|
+
inputSchema: { pageId: z.string().describe('the row id or link'), properties: z.record(z.any()).optional().describe('{"Column": value}'), trash: z.boolean().optional(), confirm: z.boolean().optional() },
|
|
20660
|
+
outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), title: z.string().optional(), url: z.string().optional(), confirmed: z.boolean().optional(), dryRun: z.boolean().optional(), changed: z.array(z.string()).optional(), row: z.any().optional(), inTrash: z.boolean().optional(), note: z.string().optional() },
|
|
20661
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
20662
|
+
}, wrap(async (a) => { const d = await apiPost('/api/notion/row', { ...a, confirm: a.confirm === true }); return ok(d.note, d); }));
|
|
20663
|
+
|
|
20664
|
+
// ---------- Canva (2026-10-27): the user's OWN Canva, the same /api/canva routes the Studio tools call ----------
|
|
20665
|
+
// Renders go in (an upload, and for an image a new editable design); finished designs come back out into the Library.
|
|
20666
|
+
// Free (no model call). Nothing here deletes or overwrites anything in Canva.
|
|
20667
|
+
server.group('files');
|
|
20668
|
+
server.registerTool('send_to_canva', {
|
|
20669
|
+
title: 'Send to Canva',
|
|
20670
|
+
description: "Send Hermoso renders to the user's Canva: each is uploaded to their Canva Uploads and, for an IMAGE, opened as a new editable Canva design (Canva makes designs from images only; a video lands in Uploads to drag into any design). Pass Library ids (L0… from list_library), Hermoso render links, or any public https image/video link (upload_file turns a local file into one); optional title, folderId (list_canva_folder / create_canva_folder), asDesign:false for the upload only. Answers with each design's edit link. Free. Needs Canva connected (Settings > Connectors > Canva).",
|
|
20671
|
+
inputSchema: { ids: z.array(z.string()).optional().describe('Library ids, e.g. L0'), urls: z.array(z.string()).optional().describe('render links or any public https image/video link'), title: z.string().optional(), folderId: z.string().optional(), asDesign: z.boolean().optional().describe('default true: an image becomes a new design') },
|
|
20672
|
+
outputSchema: { ok: z.boolean().optional(), sent: z.number().optional(), failed: z.number().optional(), results: z.array(z.any()).optional(), note: z.string().optional() },
|
|
20673
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
20674
|
+
}, wrap(async (a) => { const d = await apiPost('/api/canva/send', a); return ok(d.note, d); }));
|
|
20675
|
+
server.registerTool('list_canva_designs', {
|
|
20676
|
+
title: 'List Canva designs',
|
|
20677
|
+
description: "The user's Canva designs, newest first or by search: title, page count, edit link, view link and design id. query matches titles and content; ownership any|owned|shared; sort relevance|modified_descending|modified_ascending|title_ascending|title_descending. Read-only, free.",
|
|
20678
|
+
inputSchema: { query: z.string().optional(), ownership: z.enum(['any', 'owned', 'shared']).optional(), sort: z.enum(['relevance', 'modified_descending', 'modified_ascending', 'title_descending', 'title_ascending']).optional(), limit: z.number().optional().describe('1-100, default 25'), continuation: z.string().optional() },
|
|
20679
|
+
outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), designs: z.array(z.any()).optional(), continuation: z.string().nullable().optional(), note: z.string().optional() },
|
|
20680
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
20681
|
+
}, wrap(async (a) => {
|
|
20682
|
+
const d = await apiGet('/api/canva/designs', a);
|
|
20683
|
+
return ok(d.count ? `${d.note}\n${(d.designs || []).map((x) => `• ${x.title} (id ${x.id}${x.pages ? `, ${x.pages} page${x.pages === 1 ? '' : 's'}` : ''}) ${x.editUrl}`).join('\n')}${d.continuation ? `\n(more: continuation=${d.continuation})` : ''}` : d.note, d);
|
|
20684
|
+
}));
|
|
20685
|
+
server.registerTool('export_canva_design', {
|
|
20686
|
+
title: 'Export a Canva design',
|
|
20687
|
+
description: "Export one of the user's Canva designs (designId or its Canva link) and save the files into the Hermoso Library, ready to post, schedule or use as ad creative. format png (default), jpg, pdf, mp4, gif or pptx; pages [1,3] for some pages; quality = 1-100 for JPG or e.g. vertical_1080p for MP4 (defaults follow the design's shape); proQuality:true for Canva's pro quality (fails on unlicensed premium elements). Free.",
|
|
20688
|
+
inputSchema: { designId: z.string().describe('design id or Canva link'), format: z.enum(['png', 'jpg', 'pdf', 'mp4', 'gif', 'pptx']).optional(), pages: z.array(z.number()).optional(), quality: z.union([z.string(), z.number()]).optional(), width: z.number().optional(), height: z.number().optional(), transparentBackground: z.boolean().optional(), proQuality: z.boolean().optional() },
|
|
20689
|
+
outputSchema: { ok: z.boolean().optional(), pending: z.boolean().optional(), designId: z.string().optional(), title: z.string().optional(), format: z.string().optional(), files: z.array(z.any()).optional(), landed: z.boolean().optional(), note: z.string().optional() },
|
|
20690
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
20691
|
+
}, wrap(async (a) => { const d = await apiPost('/api/canva/export', a); return ok(d.note, d); }));
|
|
20692
|
+
server.registerTool('list_canva_brand_templates', {
|
|
20693
|
+
title: 'List Canva brand templates',
|
|
20694
|
+
description: "The user's Canva brand templates (needs Canva Pro, Teams or Enterprise). With brandTemplateId it names that template's autofill fields and their types instead. autofillOnly:true lists only templates with fields. Read-only, free.",
|
|
20695
|
+
inputSchema: { query: z.string().optional(), brandTemplateId: z.string().optional(), autofillOnly: z.boolean().optional(), limit: z.number().optional(), continuation: z.string().optional() },
|
|
20696
|
+
outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), templates: z.array(z.any()).optional(), template: z.any().optional(), fields: z.array(z.any()).optional(), note: z.string().optional() },
|
|
20697
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
20698
|
+
}, wrap(async (a) => {
|
|
20699
|
+
const d = await apiGet('/api/canva/brand-templates', a);
|
|
20700
|
+
return ok(d.templates ? `${d.note}\n${d.templates.map((t) => `• ${t.title} (id ${t.id})`).join('\n')}` : d.note, d);
|
|
20701
|
+
}));
|
|
20702
|
+
server.registerTool('autofill_canva_template', {
|
|
20703
|
+
title: 'Autofill a Canva template',
|
|
20704
|
+
description: "Fill a Canva brand template's fields into a NEW Canva design: data = { fieldName: text, or an image/video as a Library id or link }. Media is uploaded to Canva first. Needs Canva Pro, Teams or Enterprise (Canva's autofill). Answers with the new design's edit link. Free.",
|
|
20705
|
+
inputSchema: { brandTemplateId: z.string(), data: z.record(z.any()).describe('{ field: value }'), title: z.string().optional() },
|
|
20706
|
+
outputSchema: { ok: z.boolean().optional(), pending: z.boolean().optional(), design: z.any().optional(), note: z.string().optional() },
|
|
20707
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
20708
|
+
}, wrap(async (a) => { const d = await apiPost('/api/canva/autofill', a); return ok(d.note, d); }));
|
|
20709
|
+
server.registerTool('list_canva_folder', {
|
|
20710
|
+
title: 'List a Canva folder',
|
|
20711
|
+
description: "What is in one of the user's Canva folders: folderId root (their Projects, default), uploads, or a folder id. itemTypes design, folder, image, brand_template. Canva does not list videos here. Read-only, free.",
|
|
20712
|
+
inputSchema: { folderId: z.string().optional(), itemTypes: z.array(z.enum(['design', 'folder', 'image', 'brand_template'])).optional(), limit: z.number().optional(), continuation: z.string().optional() },
|
|
20713
|
+
outputSchema: { ok: z.boolean().optional(), folderId: z.string().optional(), count: z.number().optional(), items: z.array(z.any()).optional(), continuation: z.string().nullable().optional(), note: z.string().optional() },
|
|
20714
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
20715
|
+
}, wrap(async (a) => {
|
|
20716
|
+
const d = await apiGet('/api/canva/folder', { ...a, ...(Array.isArray(a.itemTypes) ? { itemTypes: a.itemTypes.join(',') } : {}) });
|
|
20717
|
+
return ok(d.count ? `${d.note}\n${(d.items || []).map((x) => `• [${x.type}] ${x.title} (id ${x.id})${x.editUrl ? ` ${x.editUrl}` : ''}`).join('\n')}` : d.note, d);
|
|
20718
|
+
}));
|
|
20719
|
+
server.registerTool('create_canva_folder', {
|
|
20720
|
+
title: 'Create a Canva folder',
|
|
20721
|
+
description: "Create a folder in the user's Canva (in their Projects by default, or inside parentFolderId). send_to_canva's folderId then files renders into it. Free.",
|
|
20722
|
+
inputSchema: { name: z.string(), parentFolderId: z.string().optional().describe('root (default), uploads or a folder id') },
|
|
20723
|
+
outputSchema: { ok: z.boolean().optional(), folder: z.any().optional(), note: z.string().optional() },
|
|
20724
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
20725
|
+
}, wrap(async (a) => { const d = await apiPost('/api/canva/folders', a); return ok(d.note, d); }));
|
|
20726
|
+
|
|
20443
20727
|
// ---------- Google Docs: export copy / brief / report as a doc the app creates (drive.file scope) ----------
|
|
20444
20728
|
server.group('files');
|
|
20445
20729
|
server.registerTool('create_doc', {
|
|
@@ -20678,8 +20962,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
20678
20962
|
// ONEDRIVE SHARING (2026-10-27): hand a render to a client or teammate as a link. Rules: server.js onedriveShare.
|
|
20679
20963
|
server.registerTool('share_onedrive_file', {
|
|
20680
20964
|
title: 'Share a OneDrive file as a link',
|
|
20681
|
-
description: "Create a SHARING LINK to a OneDrive file or folder (fileId from list_onedrive_files): type view (read-only, default), edit, or embed (an <iframe> link, personal OneDrive only); scope anonymous (anyone with the link, default), organization (work or school OneDrive only) or users (only people you invite); optional expiresAt (date or ISO) and password (personal OneDrive
|
|
20682
|
-
inputSchema: { fileId: z.string(), type: z.enum(['view', 'edit', 'embed']).optional(), scope: z.enum(['anonymous', 'organization', 'users']).optional(), expiresAt: z.string().optional().describe('e.g. 2026-11-30 or 2026-11-30T17:00:00Z'), password: z.string().optional().describe('personal OneDrive
|
|
20965
|
+
description: "Create a SHARING LINK to a OneDrive file or folder (fileId from list_onedrive_files): type view (read-only, default), edit, or embed (an <iframe> link, personal OneDrive only); scope anonymous (anyone with the link, default), organization (work or school OneDrive only) or users (only people you invite); optional expiresAt (date or ISO) and password (on a personal OneDrive both need a Microsoft 365 subscription; the refusal says so). An ANONYMOUS EDIT link lets anyone holding it change or delete the file, so it needs confirm:true after you show the user that; every other link is created straight away. If Hermoso already made the same kind of link, OneDrive returns that one and the reply says it was reused. Read back from the item's permission list. 0 credits. Needs OneDrive connected.",
|
|
20966
|
+
inputSchema: { fileId: z.string(), type: z.enum(['view', 'edit', 'embed']).optional(), scope: z.enum(['anonymous', 'organization', 'users']).optional(), expiresAt: z.string().optional().describe('e.g. 2026-11-30 or 2026-11-30T17:00:00Z'), password: z.string().optional().describe('personal OneDrive with Microsoft 365'), confirm: z.boolean().optional().describe('required for an anonymous edit link') },
|
|
20683
20967
|
outputSchema: { ok: z.boolean().optional(), dryRun: z.boolean().optional(), fileId: z.string().optional(), name: z.string().optional(), link: z.any().optional(), reused: z.boolean().optional(), verified: z.boolean().optional(), note: z.string().optional() },
|
|
20684
20968
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
20685
20969
|
}, wrap(async (a) => { const d = await apiPost('/api/onedrive/share', a); return ok(d.note, d); }));
|
|
@@ -22747,7 +23031,7 @@ function memoryNoteVerdict(text) {
|
|
|
22747
23031
|
// answer about the app. It is the OPPOSITE instruction to the permission gap above: nothing the user does fixes
|
|
22748
23032
|
// it, so it is never phrased as a reconnect. The sentence is the SERVER'S (`awaitingApprovalNote`).
|
|
22749
23033
|
const waitingNote = ''; // a feature not offered yet is not listed on the connection (owner decision, 2026-10-02)
|
|
22750
|
-
const lines = on.map(c => ` • ${c.provider}${c.agentLabel ? ` — ${c.agentLabel}` : ''} (${c.status || 'active'})${c.grantMode === 'posting' ? ' · POSTING ONLY' : ''}${c.scopeDrift?.status === 'missing' ? ` ⚠ missing ${c.scopeDrift.missing.length} permission(s) — needs a reconnect` : ''}`);
|
|
23034
|
+
const lines = on.map(c => ` • ${c.provider}${c.agentLabel ? ` — ${c.agentLabel}` : ''} (${c.status || 'active'})${c.grantMode === 'posting' ? ' · POSTING ONLY' : ''}${c.scopeDrift?.status === 'missing' ? ` ⚠ missing ${c.scopeDrift.missing.length} permission(s) — needs a reconnect` : ''}${c.needsReconnect && c.revokedReason ? ` ⚠ RECONNECT NEEDED — ${c.revokedReason}` : ''}${c.deliveryWarning?.why ? ` ⚠ ${String(c.deliveryWarning.badge || 'not delivering').toUpperCase()} — ${c.deliveryWarning.why}` : ''}`);
|
|
22751
23035
|
const postingNote = posting.length
|
|
22752
23036
|
? '\n\n' + posting.map(c => `ℹ ${c.grantModeNote}`).join('\n')
|
|
22753
23037
|
+ '\nAds, catalog, WhatsApp and Business-portfolio tools will refuse on this connection, and that is the user\'s deliberate choice rather than a fault — do not retry them. Widening it is an OAuth consent screen, so the user does it in a browser: Workspace ▸ Connectors ▸ Meta ▸ Reconnect with everything, or open ' + linkFor('meta') + '.'
|
|
@@ -22760,6 +23044,46 @@ function memoryNoteVerdict(text) {
|
|
|
22760
23044
|
const safe = { ...d, connectors: (d.connectors || []).map(({ accountLabel, ...c }) => c) };
|
|
22761
23045
|
return ok(`${on.length} connected:\n${lines.join('\n') || ' (none)'}\nAvailable to connect through a sign-in screen (needs a browser; the link is ${linkFor('<provider>')}): ${(d.providers || []).join(', ') || '(none configured)'}.\nPaste-a-key, ${widget ? 'connected in the Hermoso app under Workspace ▸ Connectors (keys are never pasted into this chat)' : 'connectable from here with connect_connector or in the app'}: ${Object.keys(KEY_CONNECTORS).join(', ')}.${on.length ? '' : (widget ? emptyWidget : empty)}${gapNote}${waitingNote}${postingNote}`, safe);
|
|
22762
23046
|
}));
|
|
23047
|
+
// ── ALERTS (2026-10-27): Slack / Discord / a signed generic webhook, for renders, scheduled posts, drafts waiting for
|
|
23048
|
+
// approval, competitor-watch findings, new leads and low credits. Rules: lib/alert-sinks.mjs; routes /api/alerts*.
|
|
23049
|
+
const alertSinkEnum = z.enum(['slack', 'discord', 'webhook']);
|
|
23050
|
+
// CHANNEL_ADMIN, NOT WORKSPACE (2026-10-27): these configure an already-connected Slack / Discord / webhook sink, the
|
|
23051
|
+
// same job as the webhook and template tools in channel_admin. In `workspace` they were on every default roster —
|
|
23052
|
+
// including a brand with nothing connected, since three possible sinks cannot be gated on one provider.
|
|
23053
|
+
server.group('channel_admin');
|
|
23054
|
+
server.registerTool('list_alert_settings', {
|
|
23055
|
+
title: 'Alert settings and recent deliveries',
|
|
23056
|
+
description: "Where this brand's ALERTS go and whether they are arriving: for each alert channel (Slack, Discord, a generic webhook) whether it is connected, which event groups it receives (renders, posts, approvals, watch, credits, leads — default all), its health (delivering / NOT DELIVERING / stopped because the channel was deleted or revoked — reconnect by pasting a new URL in the app), whether the webhook is signed, and the last deliveries with their HTTP status and any retry queued. Read-only, free.",
|
|
23057
|
+
inputSchema: {},
|
|
23058
|
+
outputSchema: { sinks: z.array(z.any()).optional(), groups: z.any().optional(), log: z.array(z.any()).optional(), note: z.string().optional() },
|
|
23059
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
23060
|
+
}, wrap(async () => {
|
|
23061
|
+
const d = await apiGet('/api/alerts');
|
|
23062
|
+
const rows = (d.log || []).slice(0, 15).map((l) => ` • ${String(l.at).slice(0, 16).replace('T', ' ')} ${l.provider} ${l.event}: ${l.outcome}${l.status ? ` (HTTP ${l.status})` : ''}${l.reason ? ` — ${l.reason}` : ''}${l.retryAt ? ` · retry at ${String(l.retryAt).slice(0, 16).replace('T', ' ')}` : ''}`);
|
|
23063
|
+
const sinks = (d.sinks || []).filter((x) => x.connected).map((x) => ` • ${x.provider}: ${x.state}${x.note ? ` — ${x.note}` : ''} · events: ${(x.events || []).join(', ') || 'none'}${x.provider === 'webhook' ? ` · ${x.signed ? 'signed' : 'UNSIGNED (rotate_webhook_signing_secret creates a secret)'}` : ''}`);
|
|
23064
|
+
return ok(`${d.note || ''}\n${sinks.join('\n')}${rows.length ? `\nRecent deliveries:\n${rows.join('\n')}` : ''}`, d);
|
|
23065
|
+
}));
|
|
23066
|
+
server.registerTool('set_alert_events', {
|
|
23067
|
+
title: 'Choose which alerts a channel receives',
|
|
23068
|
+
description: "Choose which event groups one alert channel receives. provider: slack, discord or webhook. events: any of renders (a render finished or failed), posts (a scheduled post published, partly published or failed), approvals (autopilot drafts waiting for review), watch (new competitor ads found by the competitor watch), credits (credits running low), leads (a new lead-form lead from Meta, LinkedIn or Snapchat) — or \"all\". An empty list keeps the channel connected and sends it nothing. Unknown names are refused by name. Read back from the stored setting. Free.",
|
|
23069
|
+
inputSchema: { provider: alertSinkEnum, events: z.union([z.array(z.enum(['renders', 'posts', 'approvals', 'watch', 'credits', 'leads', 'all'])), z.literal('all')]).describe('event groups, or "all"') },
|
|
23070
|
+
outputSchema: { provider: z.string().optional(), events: z.array(z.string()).nullable().optional(), note: z.string().optional() },
|
|
23071
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
23072
|
+
}, wrap(async (a) => { const d = await apiPost('/api/alerts/events', a); return ok(d.note, d); }));
|
|
23073
|
+
server.registerTool('send_test_alert', {
|
|
23074
|
+
title: 'Send a test alert',
|
|
23075
|
+
description: "Send a test alert to one alert channel (provider: slack, discord or webhook) or to every connected one, and report what each answered (delivered, or the HTTP status and the channel's own words). A test that gets through also clears a NOT DELIVERING or stopped state. The webhook test is signed like every event. Free.",
|
|
23076
|
+
inputSchema: { provider: alertSinkEnum.optional().describe('omit to test every connected alert channel') },
|
|
23077
|
+
outputSchema: { results: z.array(z.any()).optional(), note: z.string().optional() },
|
|
23078
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
23079
|
+
}, wrap(async (a) => { const d = await apiPost('/api/alerts/test', a); return ok(d.note, d); }));
|
|
23080
|
+
server.registerTool('rotate_webhook_signing_secret', {
|
|
23081
|
+
title: 'Rotate the webhook signing secret',
|
|
23082
|
+
description: "Create (or replace) the signing secret for this brand's generic alert webhook and return it ONCE — it is never readable again, so hand it to the user to store. Every event carries Standard Webhooks headers: webhook-id, webhook-timestamp and webhook-signature = v1,<base64 HMAC-SHA256 of \"id.timestamp.body\"> keyed by the base64 part after whsec_. On a rotation the OLD secret keeps signing alongside the new one for 24 hours (two signatures in the header), so a receiver being updated never rejects an event. Free.",
|
|
23083
|
+
inputSchema: {},
|
|
23084
|
+
outputSchema: { signingSecret: z.string().optional(), previousStillSignsUntil: z.string().nullable().optional(), note: z.string().optional() },
|
|
23085
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
23086
|
+
}, wrap(async () => { const d = await apiPost('/api/alerts/webhook-secret', {}); return ok(`${d.note}\nSigning secret: ${d.signingSecret}`, d); }));
|
|
22763
23087
|
// ── CONNECTOR WRITES. Connecting needs a browser (OAuth consent) and is correctly NOT headless — but the other two
|
|
22764
23088
|
// halves of connector management are, and were web-only: DISCONNECTING, and choosing WHICH accounts a brand may
|
|
22765
23089
|
// act as. The second is a permission decision that fails closed: a Page/ad account not in the list is one nothing
|
|
@@ -22933,6 +23257,7 @@ function memoryNoteVerdict(text) {
|
|
|
22933
23257
|
const CONN_PICKER_IDS = Object.keys(CONN_ACCOUNT_PICKERS);
|
|
22934
23258
|
const connIdentityLines = (rows) => rows.map(r => ` ${r.selected ? '[x]' : '[ ]'} ${r.name} (id: ${r.id}, ${r.type})${r.detail ? ` — ${r.detail}` : ''}`).join('\n');
|
|
22935
23259
|
|
|
23260
|
+
server.group('workspace');
|
|
22936
23261
|
server.registerTool('list_connector_accounts', {
|
|
22937
23262
|
title: 'List a connector’s accounts',
|
|
22938
23263
|
description: `Show every identity a connected account can act as, and which ones this PROFILE is currently allowed to use — Facebook Pages + Instagram + Meta ad accounts, Google Ads customers, LinkedIn company Pages (and the personal profile), Pinterest ad accounts, Microsoft Advertising accounts. One person often administers several; only the ticked ones can be posted to or spent from. Call this before set_connector_accounts, and let the USER pick — never guess. Providers: ${CONN_PICKER_IDS.join(', ')}. Read-only, free.`,
|