hermoso 0.1.533 → 0.1.535
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 +35 -8
- package/mcp/brand-look.mjs +120 -0
- package/mcp/client.mjs +1 -1
- package/mcp/http.mjs +37 -2
- package/mcp/openai-extensions.mjs +1 -1
- package/mcp/roster-scope.mjs +1 -1
- package/mcp/tool-hints.mjs +1 -1
- package/mcp/tools.mjs +330 -175
- package/package.json +2 -2
- package/skills/hermoso-marketing/SKILL.md +1 -1
package/README.md
CHANGED
|
@@ -22,29 +22,54 @@ campaigns behind them, all over [MCP](https://modelcontextprotocol.io) tools, a
|
|
|
22
22
|
- **Posting.** Post and schedule to your own social channels, then read each post's results. The channel list
|
|
23
23
|
is under *What it connects to* below.
|
|
24
24
|
|
|
25
|
-
## Set up in Claude,
|
|
25
|
+
## Set up in ChatGPT, Claude, Grok Bot or a coding agent
|
|
26
|
+
|
|
27
|
+
**ChatGPT:** one click. **[Add to ChatGPT](https://chatgpt.com/plugins/plugin_asdk_app_6abde87d08048191b2ee7cda76721749)** opens Hermoso in ChatGPT's app
|
|
28
|
+
directory. Press Install plugin and sign in with your Hermoso account, no key needed. Then
|
|
29
|
+
**[open a new ChatGPT chat with your first message typed](https://chatgpt.com/?q=Hermoso%2C%20set%20up%20my%20brand%20from%20my%20website%20and%20make%20my%20first%20ad)**, or paste it yourself:
|
|
30
|
+
`Hermoso, set up my brand from my website and make my first ad`
|
|
26
31
|
|
|
27
32
|
**Claude (claude.ai, Claude Desktop):** one click. **[Add to Claude](https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=Hermoso&connectorUrl=https%3A%2F%2Fapp.hermoso.ai%2Fmcp%3Fsrc%3Dreadme)**
|
|
28
33
|
opens Claude's *Add custom connector* dialog with Hermoso already filled in. Press Continue, choose **Sign in now**
|
|
29
34
|
if Claude asks about Authentication, then sign in with your Hermoso account.
|
|
30
35
|
|
|
31
|
-
|
|
36
|
+
**Grok Bot:** it cannot open the Hermoso sign-in page, so it signs in with a key. **[Create a key](https://app.hermoso.ai/?tab=api-keys)**,
|
|
37
|
+
then **[add the Hermoso Marketing Bot](https://x.ai/bot/THQqogUKuhn1whWU0hrmb)** and paste the key when it asks.
|
|
38
|
+
|
|
39
|
+
**Claude Code, Cursor, Codex and other coding agents:** one prompt. Paste this into a new chat and the agent installs
|
|
40
|
+
Hermoso, opens the sign-in and makes your first ad (this is Claude Code's install line; each agent's own is in the app
|
|
41
|
+
under MCP & CLI, and in [Install in one command](#install-in-one-command)):
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
Set up Hermoso for me. It is an AI marketing tool (hermoso.ai) that researches ads, makes image and video ads and posts to my social accounts.
|
|
45
|
+
1. Install it by running: claude plugin marketplace add hermoso-ai/hermoso && claude plugin install hermoso@hermoso
|
|
46
|
+
If the new skills only load after a restart, tell me, and use npx meanwhile.
|
|
47
|
+
2. Sign me in by running: npx -y hermoso auth login
|
|
48
|
+
It opens my browser and I will approve the connection there. If this machine has no browser, ask me for a Hermoso API key and run npx -y hermoso auth login --token <key> instead.
|
|
49
|
+
3. Once I am signed in, do one small task to show it works: tell me my Hermoso credit balance and which profile is saved.
|
|
50
|
+
4. Then set up my brand from my website and make my first ad.
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
<details>
|
|
54
|
+
<summary>Set it up by hand</summary>
|
|
55
|
+
|
|
56
|
+
**Claude by hand:** Customize → Connectors → Add → *Add custom connector*, paste the URL below, choose **Sign in now** under
|
|
32
57
|
Authentication, then Add and Connect. On a Team or Enterprise plan an Owner adds it for the organization first.
|
|
33
58
|
|
|
34
59
|
```text
|
|
35
60
|
https://app.hermoso.ai/mcp
|
|
36
61
|
```
|
|
37
62
|
|
|
63
|
+
**ChatGPT by hand:** turn on Developer mode once (Settings → Security and login), then Plugins → + → Create app →
|
|
64
|
+
Create MCP App. Paste `https://app.hermoso.ai/mcp` as the Server URL, choose OAuth, leave the advanced OAuth
|
|
65
|
+
settings empty, and press Create.
|
|
66
|
+
|
|
38
67
|
**Claude Code:**
|
|
39
68
|
|
|
40
69
|
```bash
|
|
41
70
|
claude plugin marketplace add hermoso-ai/hermoso && claude plugin install hermoso@hermoso
|
|
42
71
|
```
|
|
43
72
|
|
|
44
|
-
**ChatGPT:** turn on Developer mode once (Settings → Security and login), then Plugins → + → Create app →
|
|
45
|
-
Create MCP App. Paste `https://app.hermoso.ai/mcp` as the Server URL, choose OAuth, leave the advanced OAuth
|
|
46
|
-
settings empty, and press Create.
|
|
47
|
-
|
|
48
73
|
**Cursor:**
|
|
49
74
|
|
|
50
75
|
```bash
|
|
@@ -57,6 +82,8 @@ Or add the MCP server to Cursor's `mcp.json` after `npx -y hermoso auth login`:
|
|
|
57
82
|
{ "mcpServers": { "hermoso": { "command": "npx", "args": ["-y", "hermoso", "mcp"] } } }
|
|
58
83
|
```
|
|
59
84
|
|
|
85
|
+
</details>
|
|
86
|
+
|
|
60
87
|
Codex, Gemini CLI, Grok and the rest are in [Install in one command](#install-in-one-command).
|
|
61
88
|
|
|
62
89
|
## Examples
|
|
@@ -71,7 +98,7 @@ Ask in your own words. The agent picks the tools:
|
|
|
71
98
|
- "Compare last week's Google Ads and Meta Ads results and tell me what to pause."
|
|
72
99
|
- "Schedule this video to TikTok, Instagram Reels and YouTube Shorts for Tuesday at 9am."
|
|
73
100
|
|
|
74
|
-
**
|
|
101
|
+
**1233 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
|
|
75
102
|
catalog with exact per-render credit costs plus the full capability map.
|
|
76
103
|
|
|
77
104
|
**No markup on models** on Starter, Pro and Studio. Stop overpaying markups on AI models. Plans: [hermoso.ai/pricing](https://hermoso.ai/pricing).
|
|
@@ -311,7 +338,7 @@ block entirely if you signed in above; it is there for CI, where the process can
|
|
|
311
338
|
|
|
312
339
|
Then ask your agent: *“Generate an image ad with Hermoso.”*
|
|
313
340
|
|
|
314
|
-
### What the
|
|
341
|
+
### What the 1233 tools cover
|
|
315
342
|
|
|
316
343
|
**Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
|
|
317
344
|
Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// ── THE BRAND'S FONTS AND COLOURS, EDITABLE EVERYWHERE (2026-11-13) ─────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// A paying customer wrote in: his profile still held the fonts and palette the site scrape guessed at onboarding,
|
|
4
|
+
// and nothing let him change them. The Profile showed only the free-text "Visual style", and update_brand had no
|
|
5
|
+
// fonts or palette field. These are the two parsers every door now goes through: the MCP twins (so the CLI and
|
|
6
|
+
// /v1/tools), the Studio agent's update_brand, the web Profile (its own inline copy, pinned to these fixtures by
|
|
7
|
+
// tools/brand-look-check.mjs) and the admin brand-field route.
|
|
8
|
+
//
|
|
9
|
+
// It lives in mcp/ rather than lib/ for the reason roster-scope.mjs does: the npm package ships `cli/mcp/**` and
|
|
10
|
+
// nothing else, so `./brand-look.mjs` is the one path that resolves from BOTH tools.mjs twins. cli/mcp/brand-look.mjs
|
|
11
|
+
// is its byte twin (tools/mcp-parity.mjs sweeps every shared file).
|
|
12
|
+
//
|
|
13
|
+
// THE STORED SHAPES DO NOT CHANGE. `fonts` stays what the onboarding draft writes: an ORDERED array of family names,
|
|
14
|
+
// headline face first, body face second (templates/font-map.mjs reads it positionally). `palette` stays an array of
|
|
15
|
+
// hex strings. Both doors are accepted for fonts: the array as stored, or {headline, body}, which is merged onto the
|
|
16
|
+
// fonts already saved so changing one role leaves the other alone.
|
|
17
|
+
//
|
|
18
|
+
// NOTHING THE USER WROTE IS SILENTLY CHANGED. A value is trimmed and otherwise kept exactly as typed (case included);
|
|
19
|
+
// anything we cannot use is REFUSED with a sentence that names it, and nothing is saved. A short hex (#abc) or one
|
|
20
|
+
// without its # is refused rather than expanded, because every renderer reads the six-digit form only.
|
|
21
|
+
|
|
22
|
+
export const PALETTE_MAX = 8;
|
|
23
|
+
export const FONTS_MAX = 4;
|
|
24
|
+
export const FONT_NAME_MAX = 60;
|
|
25
|
+
export const HEX_RE = /^#[0-9a-fA-F]{6}$/;
|
|
26
|
+
|
|
27
|
+
const s = (v) => (typeof v === 'string' ? v : v == null ? '' : String(v));
|
|
28
|
+
const show = (v) => JSON.stringify(s(v).slice(0, 40));
|
|
29
|
+
|
|
30
|
+
/** One colour → '' when usable, else the sentence saying why not. Pure. */
|
|
31
|
+
export function hexRefusal(v) {
|
|
32
|
+
if (typeof v !== 'string') return `Each colour must be written as text like "#3D4C41".`;
|
|
33
|
+
const t = v.trim();
|
|
34
|
+
if (!t) return 'A colour is empty. Remove it or write it like "#3D4C41".';
|
|
35
|
+
if (HEX_RE.test(t)) return '';
|
|
36
|
+
if (/^[0-9a-fA-F]{6}$/.test(t)) return `${show(t)} needs a # in front: "#${t}".`;
|
|
37
|
+
if (/^#?[0-9a-fA-F]{3}$/.test(t)) return `${show(t)} is a short colour code. Write all six digits, like "#AABBCC".`;
|
|
38
|
+
return `${show(t)} is not a colour code. Write it as # and six digits or letters A–F, like "#3D4C41".`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The palette a caller asked for → { value } (the array to store) or { error }. An empty array clears it. Pure.
|
|
43
|
+
*/
|
|
44
|
+
export function parseBrandPalette(input) {
|
|
45
|
+
if (input == null) return { error: 'palette must be a list of colour codes, like ["#3D4C41", "#EEE4DA"].' };
|
|
46
|
+
const list = typeof input === 'string' ? input.split(/[\s,]+/).filter(Boolean) : input;
|
|
47
|
+
if (!Array.isArray(list)) return { error: 'palette must be a list of colour codes, like ["#3D4C41", "#EEE4DA"].' };
|
|
48
|
+
if (list.length > PALETTE_MAX) return { error: `A palette holds up to ${PALETTE_MAX} colours; this one has ${list.length}.` };
|
|
49
|
+
const out = [], seen = new Set();
|
|
50
|
+
for (const c of list) {
|
|
51
|
+
const why = hexRefusal(c);
|
|
52
|
+
if (why) return { error: why };
|
|
53
|
+
const t = c.trim();
|
|
54
|
+
if (seen.has(t.toLowerCase())) return { error: `${t} is in the palette twice.` };
|
|
55
|
+
seen.add(t.toLowerCase()); out.push(t);
|
|
56
|
+
}
|
|
57
|
+
return { value: out };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** One font name → '' when usable, else the sentence saying why not. Any family name is accepted. Pure. */
|
|
61
|
+
export function fontNameRefusal(v) {
|
|
62
|
+
if (typeof v !== 'string') return 'Each font must be a family name written as text, like "Josefin Sans".';
|
|
63
|
+
const t = v.trim();
|
|
64
|
+
if (!t) return 'A font name is empty.';
|
|
65
|
+
if (t.length > FONT_NAME_MAX) return `${show(t)}… is longer than ${FONT_NAME_MAX} characters; write just the family name.`;
|
|
66
|
+
// Characters that would end a CSS family name or a prompt line. Letters in any alphabet, digits, spaces, hyphens,
|
|
67
|
+
// dots, ampersands and apostrophe-free punctuation stay; nothing is stripped, the name is refused instead.
|
|
68
|
+
if (/[\u0000-\u001f"'`<>{};\\]/.test(t)) return `${show(t)} contains a character a font name cannot have (quotes, < > { } ; or \\).`;
|
|
69
|
+
return '';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The fonts a caller asked for → { value } (the ordered array to store) or { error }.
|
|
74
|
+
* `input` is an array (headline first, body second, as stored) or { headline, body } merged onto `existing`;
|
|
75
|
+
* an empty string in the object form removes that role. An empty array clears the fonts. Pure.
|
|
76
|
+
*/
|
|
77
|
+
export function parseBrandFonts(input, existing = []) {
|
|
78
|
+
const cur = (Array.isArray(existing) ? existing : []).filter((x) => typeof x === 'string' && x.trim());
|
|
79
|
+
if (Array.isArray(input)) {
|
|
80
|
+
if (input.length > FONTS_MAX) return { error: `Up to ${FONTS_MAX} fonts; the first is used for headlines and the second for body text.` };
|
|
81
|
+
const out = [];
|
|
82
|
+
for (const f of input) { const why = fontNameRefusal(f); if (why) return { error: why }; const t = f.trim(); if (out.some((o) => o.toLowerCase() === t.toLowerCase())) return { error: `${t} is listed twice.` }; out.push(t); }
|
|
83
|
+
return { value: out };
|
|
84
|
+
}
|
|
85
|
+
if (input && typeof input === 'object') {
|
|
86
|
+
const keys = Object.keys(input).filter((k) => input[k] !== undefined);
|
|
87
|
+
const bad = keys.filter((k) => k !== 'headline' && k !== 'body');
|
|
88
|
+
if (bad.length) return { error: `fonts takes headline and body (or a list); ${bad.map((k) => JSON.stringify(k)).join(', ')} is not one of them.` };
|
|
89
|
+
if (!keys.length) return { error: 'fonts needs a headline or a body font (or a list).' };
|
|
90
|
+
let head = cur[0] || '', body = cur[1] || '';
|
|
91
|
+
const rest = cur.slice(2);
|
|
92
|
+
for (const role of ['headline', 'body']) {
|
|
93
|
+
if (input[role] === undefined) continue;
|
|
94
|
+
const v = input[role] == null ? '' : input[role];
|
|
95
|
+
if (typeof v === 'string' && !v.trim()) { if (role === 'headline') head = ''; else body = ''; continue; }
|
|
96
|
+
const why = fontNameRefusal(v); if (why) return { error: why };
|
|
97
|
+
if (role === 'headline') head = v.trim(); else body = v.trim();
|
|
98
|
+
}
|
|
99
|
+
// A body font with no headline font keeps its place as the one face; the order is what renderers read.
|
|
100
|
+
const out = [head, body, ...rest].filter(Boolean);
|
|
101
|
+
const dedup = []; for (const f of out) if (!dedup.some((d) => d.toLowerCase() === f.toLowerCase())) dedup.push(f);
|
|
102
|
+
return { value: dedup };
|
|
103
|
+
}
|
|
104
|
+
if (typeof input === 'string') return { error: 'fonts takes a list (headline first, then body) or {headline, body}, not a single string.' };
|
|
105
|
+
return { error: 'fonts takes a list (headline first, then body) or {headline, body}.' };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The roles an ordered font list carries: the first is the headline face, the second the body face. Pure. */
|
|
109
|
+
export function brandFontRoles(fonts) {
|
|
110
|
+
const f = (Array.isArray(fonts) ? fonts : []).filter((x) => typeof x === 'string' && x.trim()).map((x) => x.trim());
|
|
111
|
+
return { headline: f[0] || '', body: f[1] || f[0] || '', extra: f.slice(2) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** "Benne (headlines), Josefin Sans (body)" — how a reply, get_brand and the planner name the fonts. Pure. */
|
|
115
|
+
export function describeBrandFonts(fonts) {
|
|
116
|
+
const f = (Array.isArray(fonts) ? fonts : []).filter((x) => typeof x === 'string' && x.trim()).map((x) => x.trim());
|
|
117
|
+
if (!f.length) return '';
|
|
118
|
+
if (f.length === 1) return `${f[0]} (headlines and body)`;
|
|
119
|
+
return [`${f[0]} (headlines)`, `${f[1]} (body)`, ...f.slice(2)].join(', ');
|
|
120
|
+
}
|
package/mcp/client.mjs
CHANGED
|
@@ -179,7 +179,7 @@ async function unwrap(res, { read = false } = {}) {
|
|
|
179
179
|
// ledger with the tool name off x-hermoso-tool. wrap() reports ONLY the errors that lack this marker — a local
|
|
180
180
|
// throw, a schema rejection, a socket reset — which is what stops the twins double-counting every 4xx.
|
|
181
181
|
// `connectUrl` rides a not-connected 401 with the brand already in it, so a hint never rebuilds the link.
|
|
182
|
-
throw Object.assign(new Error(msg), { status: res.status, _viaApi: true, ...(res.status === 429 ? { retryAfter: Number(body?.retryAfter) || Number(res.headers?.get?.('retry-after')) || 60 } : {}), ...(typeof body?.code === 'string' ? { apiCode: body.code } : {}), ...(body?.connector ? { connector: body.connector } : {}), ...(typeof body?.connectUrl === 'string' ? { connectUrl: body.connectUrl } : {}), ...(body?.meta?.videoChoice && typeof body.meta.videoChoice === 'object' ? { videoChoice: body.meta.videoChoice } : {}), ...(body?.metaAuthHold === true ? { metaAuthHold: true } : {}), ...(body?.meta?.welcomeClaim && typeof body.meta.welcomeClaim === 'object' && body.meta.welcomeClaim.url ? { welcomeClaim: { credits: Number(body.meta.welcomeClaim.credits) || 0, url: String(body.meta.welcomeClaim.url) } } : {}), ...(body?.remedy && typeof body.remedy === 'object' && typeof body.remedy.code === 'string' ? { remedy: body.remedy } : {}), ...(body?.buy && typeof body.buy === 'object' && body.buy.pack ? { buy: body.buy } : {}) }); // `buy` (2026-11-12): the two priced doors a balance refusal carries (server buyDoorsFor): a pack and a plan, each with its price and a checkout link // `remedy` (2026-10-25): the server's structured next step (a publish outcome, Meta's security hold), handed on whole to wrap()'s _meta and /v1 // `videoChoice` rides a 402 for a video the caller expects and cannot afford (server videoChoiceFor) — wrap() spells its options out instead of a bare top-up line
|
|
182
|
+
throw Object.assign(new Error(msg), { status: res.status, _viaApi: true, ...(res.status === 429 ? { retryAfter: Number(body?.retryAfter) || Number(res.headers?.get?.('retry-after')) || 60 } : {}), ...(typeof body?.code === 'string' ? { apiCode: body.code } : {}), ...(body?.connector ? { connector: body.connector } : {}), ...(typeof body?.connectUrl === 'string' ? { connectUrl: body.connectUrl } : {}), ...(body?.meta?.videoChoice && typeof body.meta.videoChoice === 'object' ? { videoChoice: body.meta.videoChoice } : {}), ...(body?.metaAuthHold === true ? { metaAuthHold: true } : {}), ...(body?.meta?.rewardsSettled && typeof body.meta.rewardsSettled === 'object' ? { rewardsSettled: { credits: Number(body.meta.rewardsSettled.credits) || 0 } } : {}), ...(body?.meta?.welcomeClaim && typeof body.meta.welcomeClaim === 'object' && body.meta.welcomeClaim.url ? { welcomeClaim: { credits: Number(body.meta.welcomeClaim.credits) || 0, url: String(body.meta.welcomeClaim.url) } } : {}), ...(body?.remedy && typeof body.remedy === 'object' && typeof body.remedy.code === 'string' ? { remedy: body.remedy } : {}), ...(body?.buy && typeof body.buy === 'object' && body.buy.pack ? { buy: body.buy } : {}) }); // `buy` (2026-11-12): the two priced doors a balance refusal carries (server buyDoorsFor): a pack and a plan, each with its price and a checkout link // `remedy` (2026-10-25): the server's structured next step (a publish outcome, Meta's security hold), handed on whole to wrap()'s _meta and /v1 // `videoChoice` rides a 402 for a video the caller expects and cannot afford (server videoChoiceFor) — wrap() spells its options out instead of a bare top-up line
|
|
183
183
|
}
|
|
184
184
|
// WHAT THIS CALL WAS CHARGED (2026-10-19). A paid route answers {data, creditsUsed}, and the unwrap below drops the
|
|
185
185
|
// sibling, so a synchronous paid tool's reply could never say what it cost. It is added to the running tool call's
|
package/mcp/http.mjs
CHANGED
|
@@ -177,7 +177,36 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
177
177
|
const rpcCancelledIdKeys = (body) => (Array.isArray(body) ? body : [body]).filter((m) => m && m.method === 'notifications/cancelled' && m.params && m.params.requestId !== undefined && m.params.requestId !== null).map((m) => rpcIdKey(m.params.requestId));
|
|
178
178
|
const GET_STREAM_MAX = Math.max(1, Number(process.env.MCP_GET_STREAM_MAX || 64));
|
|
179
179
|
let openGetStreams = 0;
|
|
180
|
-
|
|
180
|
+
// ── A STOPPING OR RETIRED INSTANCE HANDS ITS STREAMS BACK, IT DOES NOT LET THEM BREAK (2026-11-13) ──────────────────
|
|
181
|
+
// On SIGTERM, and when this revision has lost the traffic (server.js, lib/pump-handover.mjs retiredVerdict), every open
|
|
182
|
+
// GET stream is ended CLEANLY with an SSE `retry:` field first, the spec's own reconnection hint, so the client reopens
|
|
183
|
+
// it at once and the reopen lands on the serving revision (a stream cut by process exit reads as a network error, and
|
|
184
|
+
// some clients back off). While draining, a new GET stream is answered 503 + Retry-After (it is optional, the spec lets a
|
|
185
|
+
// server refuse it) and POSTs are untouched: a tool call in flight is never cut by this, and a session id this instance
|
|
186
|
+
// holds keeps working until its own idle expiry. A session the client reopens elsewhere gets the 404 → re-initialize
|
|
187
|
+
// path below, which is how every deploy already ends a session. undrain() reverses it (a rollback brings the traffic back).
|
|
188
|
+
const openGetResponses = new Set();
|
|
189
|
+
let draining = '';
|
|
190
|
+
const RECONNECT_HINT_MS = 1000;
|
|
191
|
+
function drain(why = 'draining') {
|
|
192
|
+
if (!draining) console.error(`[mcp-remote] draining (${why}): ending ${openGetResponses.size} notification stream(s) with a reconnect hint`);
|
|
193
|
+
draining = String(why || 'draining');
|
|
194
|
+
let ended = 0;
|
|
195
|
+
for (const res of [...openGetResponses]) {
|
|
196
|
+
try {
|
|
197
|
+
if (!res.writableEnded && !res.destroyed) {
|
|
198
|
+
// An open 200 on a GET here IS the SDK's SSE stream (its headers go through writeHead, so getHeader cannot see them;
|
|
199
|
+
// any refusal it answers is JSON and already ended). Events are written whole, so a field between them is valid SSE.
|
|
200
|
+
if (res.headersSent && res.statusCode === 200) res.write(`retry: ${RECONNECT_HINT_MS}\n\n`);
|
|
201
|
+
res.end(); ended++;
|
|
202
|
+
}
|
|
203
|
+
} catch {}
|
|
204
|
+
openGetResponses.delete(res);
|
|
205
|
+
}
|
|
206
|
+
return { ended, sessions: sessions.size };
|
|
207
|
+
}
|
|
208
|
+
function undrain() { if (draining) console.error(`[mcp-remote] no longer draining (was: ${draining})`); draining = ''; }
|
|
209
|
+
const streamStats = () => ({ open: openGetStreams, max: GET_STREAM_MAX, lifeMs: GET_STREAM_MS, draining: draining || null });
|
|
181
210
|
// THE 401 IS THE ONLY THING A STUCK AGENT EVER READS, so it carries the way out (2026-09-04). It used to be the
|
|
182
211
|
// three words `Authentication required`, and that is exactly how far a real user got: a Cursor-based client
|
|
183
212
|
// (Grok Bot) registered fine, listed all 160 tools, and then every call 401'd while its own consent card never
|
|
@@ -488,13 +517,18 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
488
517
|
|
|
489
518
|
// The stream cap, BEFORE any session work: a burst of reconnects must be refused at the door, not after allocating.
|
|
490
519
|
if (req.method === 'GET') {
|
|
520
|
+
if (draining) {
|
|
521
|
+
res.set('Retry-After', '1');
|
|
522
|
+
return res.status(503).json({ error: 'This instance is handing over to a new one: reopen the stream (it reconnects to the new instance). Tool calls (POST) are unaffected.' });
|
|
523
|
+
}
|
|
491
524
|
if (openGetStreams >= GET_STREAM_MAX) {
|
|
492
525
|
res.set('Retry-After', '30');
|
|
493
526
|
return res.status(503).json({ error: `This instance already holds ${openGetStreams} open notification streams — retry the stream in 30s. Tool calls (POST) are unaffected.` });
|
|
494
527
|
}
|
|
495
528
|
openGetStreams++;
|
|
496
529
|
const t = setTimeout(() => { try { res.end(); } catch {} }, GET_STREAM_MS); if (t && typeof t.unref === 'function') t.unref();
|
|
497
|
-
|
|
530
|
+
openGetResponses.add(res);
|
|
531
|
+
res.once('close', () => { openGetStreams = Math.max(0, openGetStreams - 1); clearTimeout(t); openGetResponses.delete(res); });
|
|
498
532
|
}
|
|
499
533
|
|
|
500
534
|
const sid = req.headers['mcp-session-id'];
|
|
@@ -686,6 +720,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
686
720
|
|
|
687
721
|
console.error(`[mcp-remote] mounted at ${BASE || '(set HERMOSO_PUBLIC_URL)'}/mcp`);
|
|
688
722
|
app.locals.mcpStreamStats = streamStats; // for the check and the admin read; the return value stays `true` as every caller asserts
|
|
723
|
+
app.locals.mcpDrain = drain; app.locals.mcpUndrain = undrain; // server.js: on SIGTERM and while this revision is retired
|
|
689
724
|
return true;
|
|
690
725
|
}
|
|
691
726
|
|
|
@@ -1278,7 +1278,7 @@ export const neutralNote = (t) => {
|
|
|
1278
1278
|
// sweeps and drift watches queued for later (measured on a fresh local account: one `agentidle` "nudge", queued for
|
|
1279
1279
|
// tomorrow, would have shown as "1 render in progress"). Mirrors server.js JOB_TYPES_HIDDEN_FROM_V1, and the check
|
|
1280
1280
|
// fails if that list gains a type this one lacks. A job queued for LATER is not in progress either.
|
|
1281
|
-
export const PANEL_HIDDEN_JOB_TYPES = Object.freeze(['shopifykeepalive', 'shopredact', 'fallate', 'storysweep', 'ttpublicurl', 'whatsappcoexsync', 'purge', 'agentidle', 'welcomewait', 'agentsetupmail', 'uploadretention', 'miniappretention', 'connectordrift', 'falwatch', 'faceassetsweep', 'searchvis', 'mcpcanary', 'welcomeads', 'lifecycleedu', 'connhealth', 'chargednothing', 'badoutcome', 'reviewask', 'emailfeedback', 'weeklyposts']);
|
|
1281
|
+
export const PANEL_HIDDEN_JOB_TYPES = Object.freeze(['shopifykeepalive', 'shopredact', 'fallate', 'storysweep', 'ttpublicurl', 'whatsappcoexsync', 'purge', 'agentidle', 'welcomewait', 'agentsetupmail', 'uploadretention', 'miniappretention', 'connectordrift', 'falwatch', 'faceassetsweep', 'searchvis', 'mcpcanary', 'welcomeads', 'lifecycleedu', 'connhealth', 'chargednothing', 'badoutcome', 'reviewask', 'emailfeedback', 'weeklyposts', 'winback']);
|
|
1282
1282
|
const ageText = (h) => (h == null || !Number.isFinite(+h) ? '' : +h < 1 ? 'just now' : +h < 48 ? `${Math.round(+h)}h ago` : `${Math.round(+h / 24)}d ago`);
|
|
1283
1283
|
const VIDEO_RE = /\.(mp4|webm|mov|m4v)([?#]|$)/i;
|
|
1284
1284
|
const clip = (s, n) => { const t = String(s ?? '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
|
package/mcp/roster-scope.mjs
CHANGED
|
@@ -230,7 +230,7 @@ export const metaAlternativeNote = (name) => INSTAGRAM_LOGIN_TOOLS.has(String(na
|
|
|
230
230
|
// The Shopify tools (list_shopify_products, publish_to_shopify_product, the marketing-activity tools) work only for an
|
|
231
231
|
// account created by installing Hermoso from the Shopify App Store: the store is derived server-side from the verified
|
|
232
232
|
// account and there is no "connect Shopify" tile. So they cannot ride TOOL_PROVIDER_RULES — every provider there must
|
|
233
|
-
// be a CONNECTOR_INFO key (property 3), and "connect it under
|
|
233
|
+
// be a CONNECTOR_INFO key (property 3), and "connect it under Connectors" would be a dead end. Instead the
|
|
234
234
|
// same /api/connectors/providers read answers `shopifyStore`: true (this account has a live store), false (it does not,
|
|
235
235
|
// or Shopify is off on this deployment), or absent when the server could not tell. ONLY a definite false holds them
|
|
236
236
|
// back — an unreadable answer keeps them, like every other read of this set (property 1). Held, they are answered by
|
package/mcp/tool-hints.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// ── HINTS: THE NEXT STEP, NAMED (2026-09-17) ───────────────────────────────────────────────────────────────────
|
|
2
2
|
//
|
|
3
|
-
// This product already tells an agent what to do next, constantly and well — "connect it under
|
|
3
|
+
// This product already tells an agent what to do next, constantly and well — "connect it under
|
|
4
4
|
// Connectors, then call again", "run buy_credits to top up", "call find_tools then call_tool", "reconnect with
|
|
5
5
|
// ?tools=all". Every one of those is a SENTENCE inside a wall of other sentences, so reading it is a parsing job
|
|
6
6
|
// an agent may or may not do. Monid rides a `Hints` block on its responses — server-suggested next command,
|