hermoso 0.1.217 → 0.1.223
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/client.mjs +30 -5
- package/mcp/roster-scope.mjs +7 -1
- package/mcp/tools.mjs +198 -5
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ scripts. Research the ads already winning in a market, generate finished image &
|
|
|
5
5
|
composited in, copy + CTA included), publish them to your own social channels, and build & manage the ad
|
|
6
6
|
campaigns behind them — all over [MCP](https://modelcontextprotocol.io) tools, a CLI, or installable Claude skills.
|
|
7
7
|
|
|
8
|
-
**
|
|
8
|
+
**822 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
|
|
9
9
|
catalog with exact per-render credit costs plus the full capability map.
|
|
10
10
|
|
|
11
11
|
**What it connects to.** Ad platforms: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads,
|
|
@@ -171,7 +171,7 @@ block entirely if you signed in above; it is there for CI, where the process can
|
|
|
171
171
|
|
|
172
172
|
Then ask your agent: *“Generate an image ad with Hermoso.”*
|
|
173
173
|
|
|
174
|
-
### What the
|
|
174
|
+
### What the 822 tools cover
|
|
175
175
|
|
|
176
176
|
**Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
|
|
177
177
|
Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
|
package/mcp/client.mjs
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// gateSpend pass. Set HERMOSO_TOKEN (a Bearer) and the SAME calls become authoritative — no
|
|
4
4
|
// changes here. We attach the x-heist-plan / x-heist-user headers (legacy wire names the server still reads) the browser also sends, purely for parity;
|
|
5
5
|
// the server treats them as non-authoritative (identity comes from the verified token / local dev user).
|
|
6
|
-
import { readFile } from 'node:fs/promises';
|
|
6
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
7
7
|
import path from 'node:path';
|
|
8
|
+
import os from 'node:os';
|
|
8
9
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
9
10
|
|
|
10
11
|
// Remote-connector identity: mcp/http.mjs wraps each request in mcpCtx.run({ token }) so every /api call a tool
|
|
@@ -34,6 +35,30 @@ export const PROFILE = (process.env.HERMOSO_PROFILE ?? process.env.HEIST_PROFILE
|
|
|
34
35
|
// PAIR IT WITH THE PROFILE UUID, not the slug: profile_members keys on profiles.id, so a client_slug is the one
|
|
35
36
|
// thing isMember() cannot match and it 403s. list_brands names both values for every workspace you can enter.
|
|
36
37
|
export const OWNER = (process.env.HERMOSO_OWNER ?? process.env.HEIST_OWNER) || '';
|
|
38
|
+
// THE PIN IS MUTABLE, AND IT OUTLIVES THE PROCESS (2026-09-09). `PROFILE` above is read ONCE at import from the env the
|
|
39
|
+
// CLI exported out of ~/.hermoso/config.json. use_brand re-pinned the KEY server-side and then every later request in
|
|
40
|
+
// the same process (and every later CLI invocation) kept sending the stale saved profile as x-heist-user — which the
|
|
41
|
+
// server ranks ABOVE the key's pin — so "Now acting on Blume" was printed while /api/workspace still answered the
|
|
42
|
+
// Hermoso brand, and the next connector write landed on the wrong brand (live 2026-09-09: a live Stripe key was
|
|
43
|
+
// overwritten by a test key). So: the pin lives here, headers() reads it, and on stdio/CLI it is written back to the
|
|
44
|
+
// CLI config so the next invocation starts on the same brand. Hosted (mcpCtx) never touches any of this.
|
|
45
|
+
let _pin = null; // { profile, owner } once use_brand pinned this process; null = whatever the env said at import
|
|
46
|
+
export const pinnedProfile = () => (_pin ? _pin.profile : (PROFILE === 'default' ? '' : PROFILE));
|
|
47
|
+
export const pinnedOwner = () => (_pin ? _pin.owner : OWNER);
|
|
48
|
+
const cliConfigFile = () => path.join(os.homedir(), '.hermoso', 'config.json');
|
|
49
|
+
export async function setPinnedProfile(profile, owner = '') {
|
|
50
|
+
const prof = !profile || profile === 'default' ? '' : String(profile);
|
|
51
|
+
if (mcpCtx.getStore()) return; // hosted: the pin lives on the api_keys row, resolved per request; nothing in-process to update
|
|
52
|
+
_pin = { profile: prof, owner: owner ? String(owner) : '' };
|
|
53
|
+
// Best-effort write-back to the CLI's own config, only when that file exists (a stdio server launched from an IDE
|
|
54
|
+
// has none, and must not create one). A failed write never fails the switch: the server-side pin already took.
|
|
55
|
+
try {
|
|
56
|
+
const f = cliConfigFile(); const cfg = JSON.parse(await readFile(f, 'utf8'));
|
|
57
|
+
if (!cfg || typeof cfg !== 'object') return;
|
|
58
|
+
cfg.profile = prof; if (_pin.owner) cfg.owner = _pin.owner; else delete cfg.owner;
|
|
59
|
+
await writeFile(f, JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
60
|
+
} catch { /* no CLI config here */ }
|
|
61
|
+
}
|
|
37
62
|
// The env-var prefix THIS build reads. tools.mjs is byte-identical across the two twins, so it cannot
|
|
38
63
|
// hardcode either name when it tells a user which variables to set — it asks its own client.
|
|
39
64
|
export const ENV_PREFIX = 'HERMOSO';
|
|
@@ -53,8 +78,8 @@ function headers(extra = {}) {
|
|
|
53
78
|
// is invisible because it succeeds. stdio/CLI keeps the env fallback: there the process and the caller are the
|
|
54
79
|
// same person. Presence of the ctx store IS "remote" (see isRemote below).
|
|
55
80
|
// 'default' is the CLI's old placeholder, not a pin — sending it overrode use_brand on every call (2026-09-04).
|
|
56
|
-
const prof = ctx ? (ctx.profile || '') : (
|
|
57
|
-
const own = ctx ? (ctx.owner || '') :
|
|
81
|
+
const prof = ctx ? (ctx.profile || '') : pinnedProfile(); // omit entirely when unpinned so the key's saved brand wins server-side
|
|
82
|
+
const own = ctx ? (ctx.owner || '') : pinnedOwner(); // the wire name is x-hermoso-owner on BOTH twins — it is the server's header, not a brand
|
|
58
83
|
const tool = toolCtx.getStore()?.tool || '';
|
|
59
84
|
const h = { 'Content-Type': 'application/json', ...(prof ? { 'x-heist-user': prof } : {}), ...(own ? { 'x-hermoso-owner': own } : {}), ...(tool ? { 'x-hermoso-tool': tool } : {}), ...extra };
|
|
60
85
|
// AN IN-PROCESS SELF-CALL IS NOT THE CUSTOMER DOING SOMETHING. Serving a hosted session makes this process call
|
|
@@ -248,7 +273,7 @@ export async function storeSuffix() {
|
|
|
248
273
|
// the fallback when an older server has no /api/workspace. A hosted call has no such fallback and must throw:
|
|
249
274
|
// guessing `bare` on a failed read is how the anchor brand gets overwritten, and a FAILED READ IS NOT EMPTY.
|
|
250
275
|
try { _suffixMemo = await fetchStoreSuffix(); }
|
|
251
|
-
catch { _suffixMemo =
|
|
276
|
+
catch { _suffixMemo = pinnedProfile(); }
|
|
252
277
|
}
|
|
253
278
|
return _suffixMemo;
|
|
254
279
|
}
|
|
@@ -418,4 +443,4 @@ export async function toRef(srcOrPath) {
|
|
|
418
443
|
return `data:${mime};base64,${buf.toString('base64')}`;
|
|
419
444
|
}
|
|
420
445
|
|
|
421
|
-
export const authState = () => ({ apiBase: API_BASE, hasToken: !!TOKEN, profile:
|
|
446
|
+
export const authState = () => ({ apiBase: API_BASE, hasToken: !!TOKEN, profile: pinnedProfile(), owner: pinnedOwner() });
|
package/mcp/roster-scope.mjs
CHANGED
|
@@ -79,7 +79,7 @@ export const TOOL_PROVIDER_RULES = [
|
|
|
79
79
|
[/linkedin/, 'linkedin'],
|
|
80
80
|
// ── Meta: ads management AND FB/IG posting are one connector ([[meta-integration]]). Threads is separate. ──
|
|
81
81
|
[/^threads_|_thread$|_threads_|^(list|search|reply_to|repost|delete|hide)_thread/, 'threads'],
|
|
82
|
-
[/_meta_|^meta_|_meta$|instagram|whatsapp/, 'meta'],
|
|
82
|
+
[/_meta_|^meta_|_meta$|instagram|whatsapp|messenger/, 'meta'], // messenger: Page marketing messages ride the Meta connector (2026-09-07)
|
|
83
83
|
// ── analytics / measurement, each its own connection ──
|
|
84
84
|
[/_analytics_|^analytics_(realtime|report)$|analytics_compatibility|analytics_stream/, 'google_analytics'],
|
|
85
85
|
[/mixpanel/, 'mixpanel'],
|
|
@@ -88,6 +88,12 @@ export const TOOL_PROVIDER_RULES = [
|
|
|
88
88
|
[/bing_webmaster/, 'bing_webmaster'],
|
|
89
89
|
[/posthog/, 'posthog'],
|
|
90
90
|
[/amplitude/, 'amplitude'],
|
|
91
|
+
// Stripe and HubSpot shipped 2026-09-09 with 21 tools and NO pattern here, so every one of them sat outside
|
|
92
|
+
// connector scoping: they were offered to a workspace with no Stripe key and no HubSpot grant, and the site
|
|
93
|
+
// channel pages counted them as 0 tools. Both are the brand's OWN account, not ours — none of these names
|
|
94
|
+
// collide with Hermoso billing, which is buy_credits / billing_status / upgrade_plan and never says "stripe".
|
|
95
|
+
[/stripe/, 'stripe'],
|
|
96
|
+
[/hubspot/, 'hubspot'],
|
|
91
97
|
// ── posting-only channels ──
|
|
92
98
|
[/youtube/, 'youtube'],
|
|
93
99
|
[/google_business|business_location|^list_business_(categories|attributes)$|^business_google_updated$/, 'google_business'],
|
package/mcp/tools.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// Spend tools hit routes guarded by gateSpend → requireAuth; locally the dev account always resolves (no auth
|
|
5
5
|
// needed today), and the SAME guard becomes authoritative under real auth — so this honors no-anon-spend as-is.
|
|
6
6
|
import { z } from 'zod';
|
|
7
|
-
import { apiGet, apiPost, apiPut, apiPatch, apiDelete, apiSSE, submitJob, getJob, jobResult, pollJob, toRef, apiUpload, apiUploadUrl, isRemote, API_BASE, PROFILE, ENV_PREFIX, mcpCtx, storeSuffix, forgetWorkspaceScope, toolCtx, reportToolError, reportDeadEnd, hostRendersWidgets, connectedProviders} from './client.mjs';
|
|
7
|
+
import { apiGet, apiPost, apiPut, apiPatch, apiDelete, apiSSE, submitJob, getJob, jobResult, pollJob, toRef, apiUpload, apiUploadUrl, isRemote, API_BASE, PROFILE, ENV_PREFIX, mcpCtx, storeSuffix, forgetWorkspaceScope, toolCtx, reportToolError, reportDeadEnd, hostRendersWidgets, connectedProviders, setPinnedProfile } from './client.mjs';
|
|
8
8
|
import { readFile } from 'node:fs/promises';
|
|
9
9
|
import { createHash } from 'node:crypto';
|
|
10
10
|
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
@@ -3275,13 +3275,23 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3275
3275
|
// server always writes both fields rather than leaving an omitted one alone.
|
|
3276
3276
|
if (hit) {
|
|
3277
3277
|
await apiPost('/api/keys/brand', { profileId: hit.id });
|
|
3278
|
+
// THE PROCESS PIN MOVES WITH THE KEY PIN (2026-09-09). The stdio/CLI twin kept sending the profile it was
|
|
3279
|
+
// launched with as x-heist-user on every later call, and the server ranks that header above the key's pin —
|
|
3280
|
+
// so the switch "took" on the api_keys row and nothing else ever saw it (a live Stripe key was overwritten on
|
|
3281
|
+
// the wrong brand). Hosted is a no-op here: its pin is read off the key on every request.
|
|
3282
|
+
await setPinnedProfile(hit.id, '');
|
|
3278
3283
|
forgetWorkspaceScope(); // the store namespace just changed server-side — a memo from before the switch would write the OLD brand
|
|
3284
|
+
// THE READ-BACK IS THE SERVER'S, NEVER THE REQUEST'S. /api/workspace resolves exactly the way every tool call
|
|
3285
|
+
// will; if it does not name the brand we just pinned, the switch did not take and saying otherwise is the bug.
|
|
3286
|
+
const w = await apiGet('/api/workspace').catch(() => null);
|
|
3287
|
+
const took = w && (hit.id === 'default' ? (!w.profileId || w.profileId === 'default' || w.storeSuffix === '') : String(w.profileId) === String(hit.id)) && !w.shared;
|
|
3288
|
+
if (!took) return { content: [{ type: 'text', text: `The switch to ${hit.name} (${hit.id}) did NOT take: after pinning, the server still resolves this connection to ${w ? `profile ${w.profileId || 'default'}${w.shared ? ' (a shared workspace)' : ''}` : 'an unreadable workspace'}. Nothing was changed on that brand. If this is a CLI, an exported ${ENV_PREFIX}_PROFILE / ${ENV_PREFIX}_OWNER in your shell is overriding the key's pin — unset it and try again.` }], isError: true };
|
|
3279
3289
|
// The roster was gated to the workspace this SESSION started in; the workspace just moved, so re-gate it.
|
|
3280
3290
|
const _rg = await regateForWorkspace(ctx);
|
|
3281
3291
|
const _rgNote = _rg && _rg.providers
|
|
3282
3292
|
? ` ${_rg.providers.length} connector(s) here — the tool roster has been re-scoped to them.`
|
|
3283
3293
|
: '';
|
|
3284
|
-
return ok(`Now acting on ${hit.name} (${hit.id}) —
|
|
3294
|
+
return ok(`Now acting on ${hit.name} (${hit.id}) — the server confirms it (workspace profile ${w.profileId || 'default'}). Brand, memory, renders and Library all scope to it.${_rgNote}`, { ok: true, brand: hit, shared: false, readBack: { profileId: w.profileId || 'default', storeSuffix: w.storeSuffix || '' } });
|
|
3285
3295
|
}
|
|
3286
3296
|
// A SHARED WORKSPACE IS SWITCHED INTO THE SAME WAY. It used to need two environment variables and a restart,
|
|
3287
3297
|
// which the hosted connector cannot do at all — so a teammate on Claude.ai saw an empty workspace and a remedy
|
|
@@ -3293,7 +3303,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3293
3303
|
const sh = shared.find(w => String(w.profileUuid || '').toLowerCase() === want || String(w.name || '').toLowerCase() === want);
|
|
3294
3304
|
if (sh) {
|
|
3295
3305
|
await apiPost('/api/keys/brand', { profileId: sh.profileUuid, ownerAccountId: sh.ownerAccountId });
|
|
3306
|
+
await setPinnedProfile(sh.profileUuid, sh.ownerAccountId);
|
|
3296
3307
|
forgetWorkspaceScope(); // a shared workspace re-keys to BARE — re-resolve rather than carry the previous brand's suffix
|
|
3308
|
+
const w = await apiGet('/api/workspace').catch(() => null);
|
|
3309
|
+
if (!w || !w.shared || String(w.profileId) !== String(sh.profileUuid)) return { content: [{ type: 'text', text: `The switch to ${sh.name || 'that shared workspace'} did NOT take: after pinning, the server still resolves this connection to ${w ? `profile ${w.profileId || 'default'}${w.shared ? '' : ' on your own account'}` : 'an unreadable workspace'}. Nothing was changed there.` }], isError: true };
|
|
3297
3310
|
// Same re-gate as the own-brand branch: a shared workspace has its OWN connectors, and the roster was gated
|
|
3298
3311
|
// to the one this session started in.
|
|
3299
3312
|
await regateForWorkspace(ctx);
|
|
@@ -4239,7 +4252,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4239
4252
|
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."),
|
|
4240
4253
|
visibility: z.enum(['public', 'unlisted', 'private', 'draft']).optional().describe("how it should be published — DEFAULT 'public' (live). Only pass something else if the user explicitly asked to stage/hide it. Not every channel supports every value; an impossible combination is refused when you schedule it, with the reason."),
|
|
4241
4254
|
visibilityByChannel: z.record(z.string()).optional().describe('override visibility for one channel, e.g. { "tiktok": "draft" } to go live everywhere but stage TikTok for review'),
|
|
4242
|
-
optimizeCopy: z.boolean().optional().describe('RECOMMENDED when one caption goes to several channels: fit the shared caption to each channel’s own rules
|
|
4255
|
+
optimizeCopy: z.boolean().optional().describe('RECOMMENDED when one caption goes to several channels: fit the shared caption to each channel’s own rules when you schedule it (the fitted caption is stored on the scheduled post, so what you scheduled is what publishes) wherever no per-channel caption was written — YouTube gets a keyword title, a structured multi-paragraph description and search tags; Instagram/TikTok hashtags; LinkedIn longer; X/Bluesky short; Pinterest keyword-rich. The angle and every claim stay the author’s; a channel with its own caption is left exactly as written. Off by default so nobody’s words are rewritten unasked.'),
|
|
4243
4256
|
},
|
|
4244
4257
|
outputSchema: { id: z.string().optional(), at: z.string().optional(), channels: z.array(z.string()).optional(), label: z.string().optional() },
|
|
4245
4258
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
@@ -4279,7 +4292,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4279
4292
|
at: z.string().optional().describe('the new time — ISO timestamp (2026-08-05T09:00:00Z) or epoch milliseconds. Must be in the future, at most 365 days out.'),
|
|
4280
4293
|
message: z.string().optional().describe('replace the caption used for every channel that has no override'),
|
|
4281
4294
|
captions: z.record(z.string()).optional().describe('replaces the WHOLE per-channel caption map — send every override you want to keep, not just the new one'),
|
|
4282
|
-
optimizeCopy: z.boolean().optional().describe('fit the shared caption to each channel’s own rules
|
|
4295
|
+
optimizeCopy: z.boolean().optional().describe('fit the shared caption to each channel’s own rules when you schedule it (the fitted caption is stored on the scheduled post, so what you scheduled is what publishes) wherever no per-channel caption was written (YouTube keyword title + structured description + tags, Instagram/TikTok hashtags, LinkedIn longer, X/Bluesky short, Pinterest keyword-rich); a channel with its own caption is left exactly as written. Send false to switch it off on this item.'),
|
|
4283
4296
|
channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky', 'telegram'])).optional().describe('replaces the channel list'),
|
|
4284
4297
|
imageUrl: z.string().optional().describe('swap the image; "" removes it'),
|
|
4285
4298
|
videoUrl: z.string().optional().describe('swap the video; "" removes it'),
|
|
@@ -9157,6 +9170,179 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9157
9170
|
const rows = (d.profiles || []).slice(0, 40).map(pr => `• ${pr.distinctId} — ${Object.entries(pr.properties || {}).slice(0, 6).map(([k, v]) => `${k}=${typeof v === 'object' ? JSON.stringify(v) : v}`).join(' · ')}`);
|
|
9158
9171
|
return ok([head, ...rows, d.note || '', d.pageNote || ''].filter(Boolean).join('\n'), d);
|
|
9159
9172
|
}));
|
|
9173
|
+
// ---------- Stripe (2026-09-09): the brand's OWN revenue, read-only via a restricted key ----------
|
|
9174
|
+
server.registerTool('stripe_report', {
|
|
9175
|
+
title: 'Stripe revenue report',
|
|
9176
|
+
description: "Revenue from the brand's OWN Stripe account: gross, refunds, net and succeeded-charge count per day/week/month, new customers in the window, and active subscriptions + MRR where the key can read them. This is the money side of the loop — read an ad, a hook or a launch against real revenue instead of clicks. Default window is the last 30 days; a window with no charges answers a real zero, not a failed read. Read-only, never moves money, free. Needs Stripe connected (Settings ▸ Connectors ▸ Stripe: paste a restricted key from Developers ▸ Restricted keys).",
|
|
9177
|
+
inputSchema: {
|
|
9178
|
+
since: z.string().optional().describe('YYYY-MM-DD (default 30 days ago)'),
|
|
9179
|
+
until: z.string().optional().describe('YYYY-MM-DD (default today)'),
|
|
9180
|
+
granularity: z.enum(['day', 'week', 'month']).optional().describe('default day'),
|
|
9181
|
+
currency: z.string().optional().describe('ISO code to isolate one currency, e.g. USD; omit to sum every currency as-is'),
|
|
9182
|
+
},
|
|
9183
|
+
outputSchema: { ok: z.boolean().optional(), since: z.string().optional(), until: z.string().optional(), granularity: z.string().optional(), currencies: z.array(z.string()).optional(), rows: z.array(z.any()).optional(), totals: z.any().optional(), subscriptions: z.any().optional(), capped: z.boolean().optional(), note: z.string().optional() },
|
|
9184
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
9185
|
+
}, wrap(async (a) => {
|
|
9186
|
+
const d = await apiPost('/api/stripe/report', a);
|
|
9187
|
+
return ok([d.note, ...(d.rows || []).map(r => `${r.period}: gross ${r.gross}, refunds ${r.refunds}, net ${r.net}, ${r.charges} charge(s), ${r.newCustomers} new customer(s)`)].filter(Boolean).join('\n'), d);
|
|
9188
|
+
}));
|
|
9189
|
+
server.registerTool('list_stripe_customers', {
|
|
9190
|
+
title: 'List Stripe customers',
|
|
9191
|
+
description: "Recent customers on the brand's OWN Stripe account — email, name, created, currency, delinquent — newest first, or one customer by exact email. Read-only, free. Needs Stripe connected.",
|
|
9192
|
+
inputSchema: { email: z.string().optional().describe('exact email match'), limit: z.number().optional().describe('1–100, default 25'), after: z.string().optional().describe('pagination cursor: the last id of the previous page') },
|
|
9193
|
+
outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), customers: z.array(z.any()).optional(), hasMore: z.boolean().optional(), note: z.string().optional() },
|
|
9194
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
9195
|
+
}, wrap(async (a) => {
|
|
9196
|
+
const d = await apiGet('/api/stripe/customers', a);
|
|
9197
|
+
return ok(d.count ? `${d.count} customer(s):\n${d.customers.map(c => `• ${c.email || c.name || c.id} — ${String(c.created).slice(0, 10)}${c.currency ? ` · ${c.currency}` : ''}${c.delinquent ? ' · delinquent' : ''} (${c.id})`).join('\n')}${d.hasMore ? '\n(more: pass after=' + d.customers[d.customers.length - 1].id + ')' : ''}` : d.note, d);
|
|
9198
|
+
}));
|
|
9199
|
+
server.registerTool('list_stripe_charges', {
|
|
9200
|
+
title: 'List Stripe charges',
|
|
9201
|
+
description: "Recent charges on the brand's OWN Stripe account — amount, currency, status, refunds, customer email, description — newest first, optionally for one customer. Read-only, free. Needs Stripe connected.",
|
|
9202
|
+
inputSchema: { customerId: z.string().optional().describe('a Stripe customer id (cus_…)'), limit: z.number().optional().describe('1–100, default 25'), after: z.string().optional().describe('pagination cursor: the last id of the previous page') },
|
|
9203
|
+
outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), charges: z.array(z.any()).optional(), hasMore: z.boolean().optional(), note: z.string().optional() },
|
|
9204
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
9205
|
+
}, wrap(async (a) => {
|
|
9206
|
+
const d = await apiGet('/api/stripe/charges', a);
|
|
9207
|
+
return ok(d.count ? `${d.count} charge(s):\n${d.charges.map(c => `• ${String(c.created).slice(0, 10)} ${c.amount} ${c.currency} ${c.status}${c.amountRefunded ? ` (refunded ${c.amountRefunded})` : ''} — ${c.customerEmail || c.customer || 'no customer'}${c.description ? ` · ${c.description}` : ''}`).join('\n')}${d.hasMore ? '\n(more: pass after=' + d.charges[d.charges.length - 1].id + ')' : ''}` : d.note, d);
|
|
9208
|
+
}));
|
|
9209
|
+
// ---------- Stripe, the full surface (2026-09-09): reads, and writes that confirm on a live key ----------
|
|
9210
|
+
server.registerTool('list_stripe_subscriptions', {
|
|
9211
|
+
title: 'list stripe subscriptions',
|
|
9212
|
+
description: "Subscriptions on the brand's OWN Stripe account with each item's price in MAJOR units (19.00 means $19.00), interval and quantity, and the MRR each contributes. With NO status it returns everything that counts toward MRR — active + trialing + past_due — and says so; pass status all, canceled, unpaid, incomplete, incomplete_expired or paused for the rest, or filter by customer or price. Read-only, free. Needs Stripe connected (Settings ▸ Connectors ▸ Stripe).",
|
|
9213
|
+
inputSchema: { status: z.string().optional(), customerId: z.string().optional(), priceId: z.string().optional(), limit: z.number().optional(), after: z.string().optional() },
|
|
9214
|
+
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
9215
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
9216
|
+
}, wrap(async (a) => { const d = await apiGet('/api/stripe/subscriptions', a); return ok(d.count ? `${d.note}\n${d.subscriptions.map(x => `• ${x.id} ${x.status} — ${x.customerEmail || x.customer}: ${x.items.map(i => `${i.quantity}×${i.amount == null ? '?' : i.amount.toFixed(2)} ${i.currency}/${i.interval || 'once'}${i.nickname ? ` (${i.nickname})` : ''}`).join(', ')} · MRR ${x.mrr}${x.cancelAtPeriodEnd ? ' · cancels at period end' : ''}`).join('\n')}` : d.note, d); }));
|
|
9217
|
+
server.registerTool('list_stripe_invoices', {
|
|
9218
|
+
title: 'list stripe invoices',
|
|
9219
|
+
description: "Invoices on the brand's Stripe account, newest first: number, status (draft, open, paid, uncollectible, void), amount due and paid, customer email, dates and the hosted invoice link; filter by status or customer. Read-only, free. Needs Stripe connected (Settings ▸ Connectors ▸ Stripe).",
|
|
9220
|
+
inputSchema: { status: z.string().optional(), customerId: z.string().optional(), limit: z.number().optional(), after: z.string().optional() },
|
|
9221
|
+
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
9222
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
9223
|
+
}, wrap(async (a) => { const d = await apiGet('/api/stripe/invoices', a); return ok(d.count ? `${d.count} invoice(s):\n${d.invoices.map(i => `• ${i.number || i.id} ${i.status} — ${i.amountPaid}/${i.amountDue} ${i.currency} — ${i.customerEmail || i.customer} — ${i.created.slice(0, 10)}${i.hostedInvoiceUrl ? ` ${i.hostedInvoiceUrl}` : ''}`).join('\n')}${d.hasMore ? '\n(more: pass after=' + d.invoices[d.invoices.length - 1].id + ')' : ''}` : d.note, d); }));
|
|
9224
|
+
server.registerTool('list_stripe_products', {
|
|
9225
|
+
title: 'list stripe products',
|
|
9226
|
+
description: "Products on the brand's Stripe account with their active prices (amount, currency, one-time or recurring interval), which is what create_stripe_payment_link and create_stripe_subscription take. active:false lists archived products too. Read-only, free. Needs Stripe connected (Settings ▸ Connectors ▸ Stripe).",
|
|
9227
|
+
inputSchema: { active: z.boolean().optional(), limit: z.number().optional(), after: z.string().optional() },
|
|
9228
|
+
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
9229
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
9230
|
+
}, wrap(async (a) => { const d = await apiGet('/api/stripe/products', a); return ok(d.count ? `${d.count} product(s):\n${d.products.map(p => `• ${p.name} (${p.id})${p.active ? '' : ' · archived'}: ${p.prices.map(x => `${x.id} ${x.amount == null ? '?' : x.amount.toFixed(2)} ${x.currency}${x.interval ? '/' + x.interval : ''}${x.nickname ? ` (${x.nickname})` : ''}`).join(', ') || 'no active price'}`).join('\n')}${d.hasMore ? '\n(more: pass after=' + d.products[d.products.length - 1].id + ')' : ''}` : d.note, d); }));
|
|
9231
|
+
server.registerTool('stripe_balance', {
|
|
9232
|
+
title: 'stripe balance',
|
|
9233
|
+
description: "The brand's Stripe balance (available and pending, per currency) and the most recent payouts with status and arrival date. Read-only, free. Needs Stripe connected (Settings ▸ Connectors ▸ Stripe).",
|
|
9234
|
+
inputSchema: { limit: z.number().optional() },
|
|
9235
|
+
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
9236
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
9237
|
+
}, wrap(async (a) => { const d = await apiGet('/api/stripe/balance', a); return ok(`${d.note}\n${(d.payouts || []).map(p => `• ${p.arrivalDate} ${p.amount} ${p.currency} ${p.status} (${p.id})`).join('\n')}`, d); }));
|
|
9238
|
+
server.registerTool('list_stripe_refunds', {
|
|
9239
|
+
title: 'list stripe refunds',
|
|
9240
|
+
description: "Refunds on the brand's Stripe account, newest first: amount, status, reason and the charge refunded; filter by charge. Read-only, free. Needs Stripe connected (Settings ▸ Connectors ▸ Stripe).",
|
|
9241
|
+
inputSchema: { chargeId: z.string().optional(), limit: z.number().optional(), after: z.string().optional() },
|
|
9242
|
+
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
9243
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
9244
|
+
}, wrap(async (a) => { const d = await apiGet('/api/stripe/refunds', a); return ok(d.count ? `${d.count} refund(s):\n${d.refunds.map(r => `• ${r.created.slice(0, 10)} ${r.amount} ${r.currency} ${r.status}${r.reason ? ` (${r.reason})` : ''} on ${r.charge} (${r.id})`).join('\n')}` : d.note, d); }));
|
|
9245
|
+
server.registerTool('list_stripe_coupons', {
|
|
9246
|
+
title: 'list stripe coupons',
|
|
9247
|
+
description: "Coupons on the brand's Stripe account: percent or amount off, duration, validity and redemptions. Read-only, free. Needs Stripe connected (Settings ▸ Connectors ▸ Stripe).",
|
|
9248
|
+
inputSchema: { limit: z.number().optional(), after: z.string().optional() },
|
|
9249
|
+
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
9250
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
9251
|
+
}, wrap(async (a) => { const d = await apiGet('/api/stripe/coupons', a); return ok(d.count ? `${d.count} coupon(s):\n${d.coupons.map(c => `• ${c.id}${c.name ? ` ${c.name}` : ''}: ${c.percentOff != null ? c.percentOff + '% off' : c.amountOff + ' ' + c.currency + ' off'}, ${c.duration}${c.durationInMonths ? ' ' + c.durationInMonths + ' months' : ''}${c.valid ? '' : ' · no longer valid'} · redeemed ${c.timesRedeemed}${c.maxRedemptions ? '/' + c.maxRedemptions : ''}`).join('\n')}` : d.note, d); }));
|
|
9252
|
+
server.registerTool('create_stripe_product', {
|
|
9253
|
+
title: 'create stripe product',
|
|
9254
|
+
description: "Create a product on the brand's Stripe account together with its default price: name, optional description, `amount` in MAJOR units — 19 or 19.00 both mean $19.00, and a zero-decimal currency like JPY is whole — a 3-letter currency, and an optional recurring interval (day, week, month, year) with intervalCount. Returns both ids, read back. On a LIVE key it shows what it is about to create and needs confirm:true; on a test key it just does it. Idempotent on its arguments. Needs Stripe connected (Settings ▸ Connectors ▸ Stripe).",
|
|
9255
|
+
inputSchema: { name: z.string(), description: z.string().optional(), amount: z.number().describe('MAJOR units — 19 or 19.00 is $19.00, never 1900'), currency: z.string(), interval: z.string().optional(), intervalCount: z.number().optional(), nickname: z.string().optional(), metadata: z.record(z.any()).optional(), confirm: z.boolean().optional() },
|
|
9256
|
+
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
9257
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
9258
|
+
}, wrap(async (a) => { const d = await apiPost('/api/stripe/product', a); return ok(d.note, d); }));
|
|
9259
|
+
server.registerTool('create_stripe_price', {
|
|
9260
|
+
title: 'create stripe price',
|
|
9261
|
+
description: "Add a price to an existing Stripe product: `amount` in MAJOR units (19 or 19.00 is $19.00, never 1900), currency, optional recurring interval and intervalCount, optional nickname. Read back. LIVE key: confirm:true after showing the preview. Idempotent on its arguments. Needs Stripe connected (Settings ▸ Connectors ▸ Stripe).",
|
|
9262
|
+
inputSchema: { productId: z.string(), amount: z.number().describe('MAJOR units — 19 or 19.00 is $19.00, never 1900'), currency: z.string(), interval: z.string().optional(), intervalCount: z.number().optional(), nickname: z.string().optional(), confirm: z.boolean().optional() },
|
|
9263
|
+
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
9264
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
9265
|
+
}, wrap(async (a) => { const d = await apiPost('/api/stripe/price', a); return ok(d.note, d); }));
|
|
9266
|
+
server.registerTool('create_stripe_payment_link', {
|
|
9267
|
+
title: 'create stripe payment link',
|
|
9268
|
+
description: "Create a shareable Stripe Payment Link: for ONE price pass priceId (price_…) with an optional quantity; for several pass lineItems: [{priceId, quantity}]. Optionally redirect to afterCompletionUrl when paid. Returns the URL. LIVE key: confirm:true after showing the preview. Idempotent on its arguments. Needs Stripe connected (Settings ▸ Connectors ▸ Stripe).",
|
|
9269
|
+
inputSchema: { priceId: z.string().optional().describe('one price — the shorthand for a single line item'), quantity: z.number().optional(), lineItems: z.array(z.object({ priceId: z.string(), quantity: z.number().optional() })).optional().describe('several prices at once; use instead of priceId'), afterCompletionUrl: z.string().optional(), metadata: z.record(z.any()).optional(), confirm: z.boolean().optional() },
|
|
9270
|
+
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
9271
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
9272
|
+
}, wrap(async (a) => { const d = await apiPost('/api/stripe/payment-link', a); return ok(d.note, d); }));
|
|
9273
|
+
server.registerTool('create_stripe_coupon', {
|
|
9274
|
+
title: 'create stripe coupon',
|
|
9275
|
+
description: "Create a Stripe coupon: exactly one of percentOff (1–100) or amountOff (major units) + currency; duration once (default), repeating (with durationInMonths) or forever; optional name, id and maxRedemptions. Read back. LIVE key: confirm:true after showing the preview. Needs Stripe connected (Settings ▸ Connectors ▸ Stripe).",
|
|
9276
|
+
inputSchema: { name: z.string().optional(), id: z.string().optional(), percentOff: z.number().optional(), amountOff: z.number().optional(), currency: z.string().optional(), duration: z.string().optional(), durationInMonths: z.number().optional(), maxRedemptions: z.number().optional(), confirm: z.boolean().optional() },
|
|
9277
|
+
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
9278
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
9279
|
+
}, wrap(async (a) => { const d = await apiPost('/api/stripe/coupon', a); return ok(d.note, d); }));
|
|
9280
|
+
server.registerTool('create_stripe_customer', {
|
|
9281
|
+
title: 'create stripe customer',
|
|
9282
|
+
description: "Create a Stripe customer by email (optional name, phone, description, metadata). If a customer with that email already exists it is returned instead and nothing is created — pass allowDuplicate:true to create a second one on purpose. LIVE key: confirm:true after showing the preview. Needs Stripe connected (Settings ▸ Connectors ▸ Stripe).",
|
|
9283
|
+
inputSchema: { email: z.string(), name: z.string().optional(), phone: z.string().optional(), description: z.string().optional(), metadata: z.record(z.any()).optional(), allowDuplicate: z.boolean().optional(), confirm: z.boolean().optional() },
|
|
9284
|
+
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
9285
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
9286
|
+
}, wrap(async (a) => { const d = await apiPost('/api/stripe/customer', a); return ok(d.note, d); }));
|
|
9287
|
+
server.registerTool('create_stripe_subscription', {
|
|
9288
|
+
title: 'create stripe subscription',
|
|
9289
|
+
description: "Subscribe a Stripe customer (cus_…) to a price (price_…), optional quantity and trialDays. THIS CHARGES THE CUSTOMER'S SAVED PAYMENT METHOD when there is no trial, so it ALWAYS needs confirm:true (test keys included) after you show the user exactly what will be created. Read back; an INCOMPLETE status means no chargeable card is on file. Needs Stripe connected (Settings ▸ Connectors ▸ Stripe).",
|
|
9290
|
+
inputSchema: { customerId: z.string(), priceId: z.string(), quantity: z.number().optional(), trialDays: z.number().optional(), metadata: z.record(z.any()).optional(), confirm: z.boolean().optional() },
|
|
9291
|
+
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
9292
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
9293
|
+
}, wrap(async (a) => { const d = await apiPost('/api/stripe/subscription', a); return ok(d.note, d); }));
|
|
9294
|
+
server.registerTool('cancel_stripe_subscription', {
|
|
9295
|
+
title: 'cancel stripe subscription',
|
|
9296
|
+
description: "Cancel a Stripe subscription: at the end of the current period by default (the customer keeps access until then), or immediately:true to end it now. ALWAYS needs confirm:true, test keys included. The read-back distinguishes the two — a period-end cancel reports the date it will end and the status it keeps until then, an immediate one reports status canceled. Needs Stripe connected (Settings ▸ Connectors ▸ Stripe).",
|
|
9297
|
+
inputSchema: { subscriptionId: z.string(), immediately: z.boolean().optional(), confirm: z.boolean().optional() },
|
|
9298
|
+
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
9299
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
9300
|
+
}, wrap(async (a) => { const d = await apiPost('/api/stripe/subscription/cancel', a); return ok(d.note, d); }));
|
|
9301
|
+
server.registerTool('refund_stripe_charge', {
|
|
9302
|
+
title: 'refund stripe charge',
|
|
9303
|
+
description: "Refund a Stripe charge (ch_… or a pi_… payment intent): the full refundable amount, or a partial `amount` in major units; optional reason duplicate / fraudulent / requested_by_customer. ALWAYS needs confirm:true, test keys included. Read back. Needs Stripe connected (Settings ▸ Connectors ▸ Stripe).",
|
|
9304
|
+
inputSchema: { chargeId: z.string(), amount: z.number().optional(), reason: z.string().optional(), confirm: z.boolean().optional() },
|
|
9305
|
+
outputSchema: { ok: z.boolean().optional(), note: z.string().optional(), count: z.number().optional(), hasMore: z.boolean().optional() },
|
|
9306
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
9307
|
+
}, wrap(async (a) => { const d = await apiPost('/api/stripe/refund', a); return ok(d.note, d); }));
|
|
9308
|
+
// ---------- HubSpot (2026-09-09): the brand's OWN CRM — contacts, an upsert by email, lead forms → contacts, deals ----------
|
|
9309
|
+
server.registerTool('list_hubspot_contacts', {
|
|
9310
|
+
title: 'List or search HubSpot contacts',
|
|
9311
|
+
description: "Contacts from the brand's OWN HubSpot CRM, newest first — email, name, lifecycle stage, lead status, created date, original source — or a search by email / first name / last name (whole-token match, wildcards allowed). Read-only, free. Needs HubSpot connected (Settings ▸ Connectors ▸ HubSpot).",
|
|
9312
|
+
inputSchema: { search: z.string().optional().describe('email or name to search for'), limit: z.number().optional().describe('1–100, default 25'), after: z.string().optional().describe('pagination cursor from the previous page') },
|
|
9313
|
+
outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), contacts: z.array(z.any()).optional(), after: z.string().nullable().optional(), note: z.string().optional() },
|
|
9314
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
9315
|
+
}, wrap(async (a) => {
|
|
9316
|
+
const d = await apiGet('/api/hubspot/contacts', a);
|
|
9317
|
+
return ok(d.count ? `${d.count} contact(s):\n${d.contacts.map(c => `• ${c.email || '(no email)'} ${[c.firstName, c.lastName].filter(Boolean).join(' ')}${c.lifecycleStage ? ` · ${c.lifecycleStage}` : ''}${c.leadStatus ? ` · ${c.leadStatus}` : ''}${c.source ? ` · ${c.source}` : ''} (${c.id})`).join('\n')}${d.after ? '\n(more: pass after=' + d.after + ')' : ''}` : d.note, d);
|
|
9318
|
+
}));
|
|
9319
|
+
server.registerTool('create_hubspot_contact', {
|
|
9320
|
+
title: 'Create or update a HubSpot contact',
|
|
9321
|
+
description: "Create a contact in the brand's HubSpot by email, or UPDATE the contact that already has that email — HubSpot's duplicate answer carries the existing id, so this is an UPSERT and never makes a twin. Optional firstname, lastname, phone, company, website, lifecyclestage, hs_lead_status, plus any other writable contact property in `properties` (by internal name). The contact is READ BACK after the write; the reply says created or updated. Free. Needs HubSpot connected.",
|
|
9322
|
+
inputSchema: { email: z.string().describe('the contact email — the key HubSpot de-duplicates on'), firstname: z.string().optional(), lastname: z.string().optional(), phone: z.string().optional(), company: z.string().optional(), website: z.string().optional(), lifecyclestage: z.string().optional(), hs_lead_status: z.string().optional(), properties: z.record(z.any()).optional().describe('any other writable contact properties, by internal name') },
|
|
9323
|
+
outputSchema: { ok: z.boolean().optional(), action: z.string().optional(), contact: z.any().optional(), note: z.string().optional() },
|
|
9324
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
9325
|
+
}, wrap(async (a) => { const d = await apiPost('/api/hubspot/contact', a); return ok(d.note, d); }));
|
|
9326
|
+
server.registerTool('sync_leads_to_hubspot', {
|
|
9327
|
+
title: 'Push lead-form leads into HubSpot',
|
|
9328
|
+
description: "Push the leads collected by the brand's Meta and LinkedIn LEAD FORMS into HubSpot as contacts. Each lead is upserted by email with lead status NEW and the platform + form name written into the contact's Message property (a default HubSpot property, so no schema scope is needed). Synced lead ids are remembered per workspace, so a re-run, a retry or a second agent never creates twins. Reports created / updated / already-synced / no-email per platform; a platform that is not connected is REPORTED, not fatal. dryRun:true lists what would be synced and writes nothing. Free. Needs HubSpot connected, plus Meta and/or LinkedIn for the leads.",
|
|
9329
|
+
inputSchema: { since: z.string().optional().describe('ISO date; default 7 days ago'), dryRun: z.boolean().optional().describe('list what would be synced and write nothing') },
|
|
9330
|
+
outputSchema: { ok: z.boolean().optional(), dryRun: z.boolean().optional(), since: z.string().optional(), gathered: z.number().optional(), perPlatform: z.any().optional(), unavailable: z.any().optional(), results: z.array(z.any()).optional(), note: z.string().optional() },
|
|
9331
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
9332
|
+
}, wrap(async (a) => {
|
|
9333
|
+
const d = await apiPost('/api/hubspot/sync-leads', a);
|
|
9334
|
+
return ok(`${d.note}\n${(d.results || []).slice(0, 40).map(r => `• ${r.key}: ${r.action}${r.email ? ` ${r.email}` : ''}${r.contactId ? ` → ${r.contactId}` : ''}${r.error ? ` (${r.error})` : ''}`).join('\n')}`, d);
|
|
9335
|
+
}));
|
|
9336
|
+
server.registerTool('hubspot_deals_report', {
|
|
9337
|
+
title: 'HubSpot deals report',
|
|
9338
|
+
description: "Deals from the brand's HubSpot created in the window (default the last 90 days): count and amount by stage (labels resolved through the deal pipelines, closed-won recognised from the stage's own metadata), by original source, a monthly created / closed-won series, and totals. Capped at 2,000 deals and says so. Read-only, free. Needs HubSpot connected.",
|
|
9339
|
+
inputSchema: { since: z.string().optional().describe('YYYY-MM-DD, default 90 days ago'), until: z.string().optional().describe('YYYY-MM-DD, default today') },
|
|
9340
|
+
outputSchema: { ok: z.boolean().optional(), since: z.string().optional(), until: z.string().optional(), byStage: z.array(z.any()).optional(), bySource: z.array(z.any()).optional(), monthly: z.array(z.any()).optional(), totals: z.any().optional(), capped: z.boolean().optional(), note: z.string().optional() },
|
|
9341
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
9342
|
+
}, wrap(async (a) => {
|
|
9343
|
+
const d = await apiPost('/api/hubspot/deals/report', a);
|
|
9344
|
+
return ok([d.note, `By stage: ${(d.byStage || []).map(x => `${x.pipeline ? x.pipeline + ' / ' : ''}${x.stage} ${x.count} (${x.amount})`).join('; ') || 'none'}`, `By source: ${(d.bySource || []).map(x => `${x.source} ${x.count} (${x.amount})`).join('; ') || 'none'}`, `Monthly: ${(d.monthly || []).map(m => `${m.month} created ${m.created} (${m.createdAmount}), won ${m.closedWon} (${m.closedWonAmount})`).join('; ') || 'none'}`].join('\n'), d);
|
|
9345
|
+
}));
|
|
9160
9346
|
server.group('ads'); // end of the measurement block — back to paid-campaign management
|
|
9161
9347
|
// ---------- Microsoft Advertising (Bing Ads): read + manage. Same spend law as Google — everything is created
|
|
9162
9348
|
// Paused, only an explicit confirm:true arms real money, and every narration comes from a READ-BACK.
|
|
@@ -10810,6 +10996,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10810
10996
|
platforms: z.array(z.enum(['ios_app', 'android_app', 'web'])).optional().describe('WHICH CHATGPT SURFACES THIS CAMPAIGN RUNS ON — OpenAI’s “Eligible platforms”: any of ios_app (the ChatGPT iOS app), android_app (the Android app) and web (chatgpt.com in a browser). OMIT IT to run on all three, which is the default and almost always right; naming a subset STOPS the ad serving everywhere else. There is no empty state — ChatGPT Ads refuses an empty list — so widening back means naming all three.'),
|
|
10811
10997
|
customAudienceIds: z.array(z.string()).optional().describe('TARGET a CUSTOM AUDIENCE — ids from list_openai_ads_audiences (created with create_openai_ads_audience, then filled with members). Until 2026-08-12 an audience could be created AND uploaded and then pointed at nothing: this is the field that consumes them. Combines with geo — the ad reaches people in the named locations who are ALSO in these audiences.'),
|
|
10812
10998
|
excludedCustomAudienceIds: z.array(z.string()).optional().describe('EXCLUDE custom audiences — same ids, opposite effect (suppressing existing customers, say). An id in BOTH lists is refused rather than resolved by a guess, because OpenAI does not document which side wins.'),
|
|
10999
|
+
conversionEventSettingIds: z.array(z.string()).optional().describe('REQUIRED when biddingType is "conversions" (oCPC): exactly one active conversion-event-setting id from list_openai_ads_conversion_events, the event ChatGPT Ads optimises toward. Ignored for clicks/impressions bidding.'),
|
|
10813
11000
|
startTime: z.number().optional().describe('unix seconds'), endTime: z.number().optional().describe('unix seconds'),
|
|
10814
11001
|
adGroup: z.object({
|
|
10815
11002
|
name: z.string(), description: z.string().optional(),
|
|
@@ -10904,6 +11091,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10904
11091
|
platforms: z.array(z.enum(['ios_app', 'android_app', 'web'])).optional().describe('REPLACES which ChatGPT surfaces the campaign runs on (ios_app / android_app / web). Wholesale like the rest of targeting: a patch that changes geo or audiences on a campaign that already restricts platforms is REFUSED by name rather than silently widening it back to every surface. There is no [] — name all three to go back to everywhere.'),
|
|
10905
11092
|
customAudienceIds: z.array(z.string()).optional().describe('REPLACES the campaign’s targeted custom audiences. TARGETING IS REPLACED WHOLESALE, not merged — a patch that omits something the campaign already targets is REFUSED by name rather than silently dropping it, so restate it here or pass [] to clear it deliberately.'),
|
|
10906
11093
|
excludedCustomAudienceIds: z.array(z.string()).optional().describe('REPLACES the campaign’s excluded custom audiences — same wholesale rule as customAudienceIds.'),
|
|
11094
|
+
conversionEventSettingIds: z.array(z.string()).optional().describe('REPLACE the conversion-event-setting id a "conversions" campaign optimises toward (exactly one active id from list_openai_ads_conversion_events).'),
|
|
10907
11095
|
contextHints: z.array(z.string()).optional().describe('REPLACES the existing list'),
|
|
10908
11096
|
maxBid: z.number().optional(), billingEvent: z.enum(['click', 'impression']).optional().describe('required alongside maxBid — bidding is replaced wholesale'),
|
|
10909
11097
|
bidStrategy: z.enum(['fixed_bid', 'maximize_clicks', 'maximize_conversions']).optional().describe('CHANGE HOW THIS AD GROUP BIDS (OpenAI’s “Maximize results”). BIDDING IS REPLACED WHOLESALE, so a patch that moves the bid without restating the strategy would DEMOTE a maximize_* ad group to a fixed bid, and one that sets a strategy without restating maxBid DELETES the cap — both are refused by name with what would have been lost.'),
|
|
@@ -17627,7 +17815,12 @@ function memoryNoteVerdict(text) {
|
|
|
17627
17815
|
}, wrap(async ({ path, params }) => {
|
|
17628
17816
|
const d = await apiGet('/api/sc/run', { __path: path, ...qp(params || {}) });
|
|
17629
17817
|
const raw = JSON.stringify(d);
|
|
17630
|
-
|
|
17818
|
+
const truncated = raw.length > 24000;
|
|
17819
|
+
const text = truncated ? raw.slice(0, 24000) + '\n… (truncated — narrow the query or use a dedicated search_* tool)' : raw;
|
|
17820
|
+
// THE PAYLOAD RIDES IN BOTH PLACES (2026-09-09). ok(text) with no data set structuredContent to {} — and a host that
|
|
17821
|
+
// renders structuredContent over the text block (claude.ai does) showed a bare `{}` for a 40KB Instagram profile
|
|
17822
|
+
// that had arrived intact in the text. Same bounded copy in structuredContent, so either rendering answers.
|
|
17823
|
+
return ok(text, truncated ? { path, truncated: true, text } : { path, truncated: false, data: d });
|
|
17631
17824
|
}));
|
|
17632
17825
|
|
|
17633
17826
|
// ---------- brand onboarding ----------
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.223",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
|
-
"description": "AI ad studio and marketing MCP server with
|
|
5
|
+
"description": "AI ad studio and marketing MCP server with 822 tools. Research the ads already running in any market, generate finished image, video and UGC avatar ads, publish and schedule them to your own channels, build and manage the ad campaigns behind them, and read what they achieved. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads and ChatGPT Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"hermoso": "bin/hermoso.mjs"
|