hermoso 0.1.303 → 0.1.305
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/bin/hermoso.mjs +10 -3
- package/mcp/client.mjs +16 -1
- package/mcp/hermoso-mcp.mjs +6 -1
- package/mcp/http.mjs +23 -0
- package/mcp/tools.mjs +5 -2
- package/package.json +1 -1
package/bin/hermoso.mjs
CHANGED
|
@@ -85,7 +85,7 @@ async function main() {
|
|
|
85
85
|
return console.log(`✓ Saved. API: ${apiBase}${cfg.token ? ' · token stored' : ''}`);
|
|
86
86
|
}
|
|
87
87
|
if (group === 'version' || flags.version) {
|
|
88
|
-
console.log(`hermoso-cli ${CLI_VERSION} · API ${cfg.apiBase || process.env.HERMOSO_API_BASE || 'https://app.hermoso.ai'} · ${cfg.token ? 'authed' : '
|
|
88
|
+
console.log(`hermoso-cli ${CLI_VERSION} · API ${cfg.apiBase || process.env.HERMOSO_API_BASE || 'https://app.hermoso.ai'} · ${cfg.token || process.env.HERMOSO_TOKEN ? 'authed' : 'not signed in — run: hermoso auth login'}`);
|
|
89
89
|
return;
|
|
90
90
|
}
|
|
91
91
|
|
|
@@ -120,9 +120,16 @@ async function main() {
|
|
|
120
120
|
// pill. Reading only `balance` printed "Balance: undefined credits" against prod for every signed-in user
|
|
121
121
|
// (measured 2026-08-19). Same expression the hermoso_credits MCP tool uses, so the two cannot disagree.
|
|
122
122
|
const bal = d.accountBalance ?? d.balance;
|
|
123
|
+
// No key, no balance: /api/credits answers an anonymous caller 200 with a null balance, and "Balance: — credits"
|
|
124
|
+
// read as an empty account rather than a missing sign-in (2026-09-25).
|
|
125
|
+
if (bal == null && api.signedOut()) return die(api.SIGN_IN_HINT);
|
|
123
126
|
return out(`Balance: ${bal ?? '—'} credits`, d); }
|
|
124
127
|
case 'brand': {
|
|
125
|
-
|
|
128
|
+
// BARE `hermoso brand` SHOWS THE SAVED BRAND (2026-09-25). The docs have always listed it as "the saved brand
|
|
129
|
+
// profile" and it died with the `brand draft` usage line instead — the first command a new user copies off
|
|
130
|
+
// the CLI page. It is get_brand, run through the same handler the MCP twins use.
|
|
131
|
+
if (!sub) { const reg = await import('../mcp/registry.mjs'); return await runTool(reg, 'get_brand', flags, []); }
|
|
132
|
+
if (sub !== 'draft') return die('usage: hermoso brand the saved brand profile\n hermoso brand draft (--domain <d> | --description <t> | --social <h> --platform <p>)');
|
|
126
133
|
const body = flags.domain ? { domain: flags.domain } : flags.description ? { description: flags.description } : flags.social ? { socialHandle: flags.social, platform: flags.platform || 'instagram' } : null;
|
|
127
134
|
if (!body) return die('give --domain, --description, or --social');
|
|
128
135
|
const d = await api.apiPost('/api/brand/draft', body); const p = d.profile || d;
|
|
@@ -262,7 +269,7 @@ async function main() {
|
|
|
262
269
|
}
|
|
263
270
|
console.log(`hermoso <command>
|
|
264
271
|
auth login [--url <base>] [--token <t>] credits capabilities
|
|
265
|
-
brand draft (--domain|--description|--social …)
|
|
272
|
+
brand · brand draft (--domain|--description|--social …) create --brand --product [--format]
|
|
266
273
|
generate image --prompt [--ref] [--model] [--aspect] generate video|avatar|stitch … [--wait]
|
|
267
274
|
jobs list | jobs get <id> [--wait] competitors <domain>
|
|
268
275
|
ads pull (--company|--domain) research "<request>"
|
package/mcp/client.mjs
CHANGED
|
@@ -101,12 +101,27 @@ function headers(extra = {}) {
|
|
|
101
101
|
return h;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
// NOT SIGNED IN, SAID AS HOW TO FIX IT (2026-09-25). A stdio server or CLI started with no key (a new user who skipped
|
|
105
|
+
// `auth login`, a directory's "try this server", an IDE config missing HERMOSO_TOKEN) got the server's bare
|
|
106
|
+
// "Sign in to continue." on every account tool, and `hermoso_credits` printed "Balance: undefined credits" because
|
|
107
|
+
// /api/credits answers an anonymous caller 200 with a null balance. Neither says what to DO. Only when the process
|
|
108
|
+
// itself holds no credential: a hosted request (mcpCtx) always carries its caller's bearer, and a localhost base
|
|
109
|
+
// needs no auth at all. A caller who DOES send a key and still gets a 401 has a bad or revoked key, which the
|
|
110
|
+
// server's own message already says.
|
|
111
|
+
export const SIGN_IN_HINT = 'Not signed in. Run `npx -y hermoso auth login` (it opens a browser), or set HERMOSO_TOKEN to an agent key (hmk_…) created at app.hermoso.ai on the MCP & CLI tab, under Terminal & API keys.';
|
|
112
|
+
export const signedOut = () => !mcpCtx.getStore() && !TOKEN && !/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/.test(API_BASE);
|
|
113
|
+
/** The 401 sentence a caller with no credential reads. PURE (the signed-out verdict is an argument) so a check runs it. */
|
|
114
|
+
export function signInMessage(status, body, msg, isSignedOut = signedOut()) {
|
|
115
|
+
if (Number(status) !== 401 || body?.connector || !isSignedOut) return msg;
|
|
116
|
+
return /^sign in to continue\.?$/i.test(String(msg || '').trim()) ? SIGN_IN_HINT : `${msg} ${SIGN_IN_HINT}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
104
119
|
// unwrap the {data}|{error} envelope; throw a clean Error (with .status) on failure
|
|
105
120
|
async function unwrap(res) {
|
|
106
121
|
let body = null;
|
|
107
122
|
try { body = await res.json(); } catch {}
|
|
108
123
|
if (!res.ok) {
|
|
109
|
-
const msg = (body && (body.error || body.message)) || `HTTP ${res.status}
|
|
124
|
+
const msg = signInMessage(res.status, body, (body && (body.error || body.message)) || `HTTP ${res.status}`);
|
|
110
125
|
// `_viaApi` MARKS AN ERROR THAT ALREADY REACHED THE SERVER, so route() has already recorded it in the error
|
|
111
126
|
// ledger with the tool name off x-hermoso-tool. wrap() reports ONLY the errors that lack this marker — a local
|
|
112
127
|
// throw, a schema rejection, a socket reset — which is what stops the twins double-counting every 4xx.
|
package/mcp/hermoso-mcp.mjs
CHANGED
|
@@ -13,7 +13,12 @@ import { API_BASE, connectedProviders } from './client.mjs';
|
|
|
13
13
|
|
|
14
14
|
// instructions = the full capability map (ad spy · create · raw model playground · account) — one source of truth
|
|
15
15
|
// in tools.mjs, shared with the hosted connector (http.mjs), so every surface tells agents the same breadth.
|
|
16
|
-
|
|
16
|
+
// THE REAL VERSION IN serverInfo (2026-09-25). This said '1.0.0' — a version the package has never had — so a client's
|
|
17
|
+
// server list and a directory that reads serverInfo both reported it, while npm shipped 0.1.x. Read from the package
|
|
18
|
+
// this file ships in (cli/package.json in the npm package; the app's own package.json in the app repo).
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
const PKG_VERSION = (() => { try { return createRequire(import.meta.url)('../package.json').version || '0.0.0'; } catch { return '0.0.0'; } })();
|
|
21
|
+
const server = new McpServer({ name: 'hermoso-mcp', version: PKG_VERSION }, {
|
|
17
22
|
instructions: MCP_INSTRUCTIONS,
|
|
18
23
|
});
|
|
19
24
|
|
package/mcp/http.mjs
CHANGED
|
@@ -283,6 +283,14 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
283
283
|
// a 22-tool server on ~430 directory pages. So an UNSTATED scope here resolves to the full pre-core-first
|
|
284
284
|
// default rather than to the session default; an explicit `?tools=` still wins, exactly as it does below.
|
|
285
285
|
registerTools(server, { only: scope?.groups || [...DEFAULT_TOOL_GROUPS], directory: scope?.directory || false, widgetHost: isWidgetHost(clientInfoOf(req.body), req) , hosted: true }); // metadata only — tools/list never invokes a handler, and tools/call can't reach here
|
|
286
|
+
// WHO PROBES US WITHOUT A TOKEN, BY NAME (2026-09-25). A host's add-connector dialog decides "sign-in needed or
|
|
287
|
+
// not" from THIS answer: ChatGPT probes with an empty body and gets the 401; claude.ai's dialog sends a real
|
|
288
|
+
// tokenless initialize (UA python-httpx) and our 200 made it pre-select "No sign-in". The UA alone cannot tell
|
|
289
|
+
// that probe from a registry crawler, so the clientInfo it names is logged, one line per anonymous initialize,
|
|
290
|
+
// so the next matcher is chosen from evidence (the Grok one was).
|
|
291
|
+
if (methodsOf(req.body).includes('initialize')) {
|
|
292
|
+
try { const m = (Array.isArray(req.body) ? req.body : [req.body]).find((x) => x && x.method === 'initialize'); const ci = m?.params?.clientInfo || {}; console.error(`[mcp-anon] initialize client=${JSON.stringify(String(ci.name || '').slice(0, 64))} v=${JSON.stringify(String(ci.version || '').slice(0, 24))} proto=${String(m?.params?.protocolVersion || '').slice(0, 16)} ua=${JSON.stringify(String(req.headers['user-agent'] || '').slice(0, 80))} src=${srcOf(req) || '-'}`); } catch {}
|
|
293
|
+
}
|
|
286
294
|
if (typeof onAnonDiscovery === 'function' && methodsOf(req.body).includes('tools/list')) { try { onAnonDiscovery({ client: clientInfoOf(req.body), ua: String(req.headers['user-agent'] || '').slice(0, 120), src: srcOf(req) }); } catch {} }
|
|
287
295
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
288
296
|
res.on('close', () => { try { transport.close(); server.close(); } catch {} });
|
|
@@ -334,6 +342,21 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
334
342
|
// DELETE too. The one exception is an `initialize`, which IS a client starting over: strict in what we send,
|
|
335
343
|
// liberal in what we accept, because the entire point of this fix is that nothing here bricks a client.
|
|
336
344
|
if (sid && !init) return sessionGone(res);
|
|
345
|
+
// ── `server/discover` IS A HOST ASKING WHICH ERA WE SPEAK, AND THE 400 IS THE ANSWER (2026-09-25) ────────────
|
|
346
|
+
// ChatGPT (openai-mcp/1.0.0) and Claude Code (2.1.282) open EVERY connection with the MCP 2026-07-28 probe
|
|
347
|
+
// `server/discover` (headers MCP-Protocol-Version: 2026-07-28 + Mcp-Method, the version in params._meta) and
|
|
348
|
+
// only then fall back to `initialize`. That is the first POST after a token, and it is a 400 in the request log
|
|
349
|
+
// on every connect — which read like the connect failing. It is not: the spec's own compatibility matrix
|
|
350
|
+
// (modelcontextprotocol.io/specification/2026-07-28/basic/versioning, "Dual-era client / Legacy server:
|
|
351
|
+
// Works") says a dual-era host falls back when "the modern request returns a 4xx without a recognized modern
|
|
352
|
+
// error body". So this answer is deliberate and must stay LEGACY-shaped: a -32601 or any other modern code
|
|
353
|
+
// (-32020/-32021/-32022) would tell the host we are a modern server and it would stop falling back — every
|
|
354
|
+
// host would stall at connect. Serving the modern revision for real is a transport rewrite (no protocol-level
|
|
355
|
+
// sessions, per-request _meta, subscriptions/listen) that SDK 1.29 does not implement; this is the answer
|
|
356
|
+
// until then. Pinned by tools/mcp-connect-canary-check.mjs and replayed daily by the connect canary.
|
|
357
|
+
if (!init && req.method === 'POST' && methodsOf(req.body).includes('server/discover')) {
|
|
358
|
+
return res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Bad Request: this server speaks the initialize-based MCP revisions (2025-11-25 and earlier). Send initialize to open a session.' }, id: null });
|
|
359
|
+
}
|
|
337
360
|
// Only an `initialize` may mint a session. Anything else naming no session at all is the SDK's own 400 —
|
|
338
361
|
// answered here so we never build a server whose only job would be to reject the request.
|
|
339
362
|
if (!init) return needSession(res);
|
package/mcp/tools.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
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
7
|
import { absolutizeAssetUrl, publicOrigin } from './public-url.mjs';
|
|
8
|
-
import { apiGet, apiPost, apiPut, apiPatch, apiDelete, apiSSE, submitJob, getJob, jobResult, pollJob, jobWaitMs, toRef, localRefVerdict, apiUpload, apiUploadUrl, isRemote, API_BASE, PROFILE, ENV_PREFIX, mcpCtx, storeSuffix, forgetWorkspaceScope, toolCtx, reportToolError, reportDeadEnd, hostRendersWidgets, connectedProviders, setPinnedProfile } from './client.mjs';
|
|
8
|
+
import { apiGet, apiPost, apiPut, apiPatch, apiDelete, apiSSE, submitJob, getJob, jobResult, pollJob, jobWaitMs, toRef, localRefVerdict, apiUpload, apiUploadUrl, isRemote, API_BASE, PROFILE, ENV_PREFIX, mcpCtx, storeSuffix, forgetWorkspaceScope, toolCtx, reportToolError, reportDeadEnd, hostRendersWidgets, connectedProviders, setPinnedProfile, signedOut, SIGN_IN_HINT } from './client.mjs';
|
|
9
9
|
import { readFile } from 'node:fs/promises';
|
|
10
10
|
import { createHash } from 'node:crypto';
|
|
11
11
|
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
@@ -447,7 +447,7 @@ const wrap = (fn) => {
|
|
|
447
447
|
}
|
|
448
448
|
catch (e) {
|
|
449
449
|
try { recordToolOutcome(_tool, { ok: false, ms: Date.now() - _t0 }); } catch {}
|
|
450
|
-
if (!e?._viaApi) { try { reportToolError(_tool, e); } catch {} }
|
|
450
|
+
if (!e?._viaApi && !e?._signedOut) { try { reportToolError(_tool, e); } catch {} } // a caller with no key is not a defect, and its report would be anonymous noise
|
|
451
451
|
let msg = `Error: ${e?.message || e}`;
|
|
452
452
|
// THE SAME TWO PIECES OF ADVICE, NAMED (2026-09-17). Everything below stays exactly as it was — the prose is
|
|
453
453
|
// what a model reads and what half the hosts show. `_hints` is the identical advice keyed as {do, why}, so a
|
|
@@ -3621,6 +3621,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3621
3621
|
}, wrap(async () => {
|
|
3622
3622
|
const d = await apiGet('/api/credits');
|
|
3623
3623
|
const bal = d.accountBalance ?? d.balance; // accountBalance = the caller's Hermoso credits (authed); balance = the local-dev usage pill
|
|
3624
|
+
// NO BALANCE IS NOT A NUMBER (2026-09-25): this printed "Balance: undefined credits" to every caller with no key,
|
|
3625
|
+
// because /api/credits answers an anonymous caller 200 with a null balance. Say why there is none instead.
|
|
3626
|
+
if (bal == null) throw Object.assign(new Error(signedOut() ? SIGN_IN_HINT : 'The balance could not be read just now. Try again in a moment; billing_status reads it too.'), { status: signedOut() ? 401 : 503, _signedOut: signedOut() });
|
|
3624
3627
|
// THE CHARGES THEMSELVES (2026-09-12): the same ledger list as Billing ▸ Usage, so "where did my credits go" has an
|
|
3625
3628
|
// answer here too. Best effort: a reply about the balance never fails on the history read.
|
|
3626
3629
|
let recent = ''; try { const u = await apiGet('/api/billing/usage', { limit: 10 }); if (u?.items?.length) recent = `\nRecent charges (newest first):\n${u.items.map((r) => `• ${String(r.at).slice(0, 16).replace('T', ' ')} UTC · ${r.label} · ${r.credits} credits`).join('\n')}`; } catch {}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.305",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
5
|
"description": "Marketing on autopilot, run from your own AI agent. 863 tools. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on every plan; credits are only for generating creative and for Ad Spy research. 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",
|