hermoso 0.1.504 → 0.1.505
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/mcp/http.mjs +41 -1
- package/package.json +1 -1
package/mcp/http.mjs
CHANGED
|
@@ -162,6 +162,10 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl, onSessionStar
|
|
|
162
162
|
const GET_STREAM_MS = Math.max(1000, Number(process.env.MCP_GET_STREAM_MS || 5 * 60e3));
|
|
163
163
|
const MCP_CALL_WALL_MS = Number(process.env.MCP_CALL_WALL_MS) || 10 * 60 * 1000; // a tools/call still open after this is ended, so a ghost cannot pin a deploy or a slot
|
|
164
164
|
const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [body]; return msgs.map(m => m?.method === 'tools/call' ? String(m.params?.name || '?').slice(0, 60) : '').filter(Boolean).join(',') || '?'; };
|
|
165
|
+
// A JSON-RPC id as the SDK's own Map sees it: 7 and "7" are different keys there, so they are different here.
|
|
166
|
+
const rpcIdKey = (id) => `${typeof id}:${String(id)}`;
|
|
167
|
+
const rpcRequestIdKeys = (body) => (Array.isArray(body) ? body : [body]).filter((m) => m && typeof m.method === 'string' && m.id !== undefined && m.id !== null).map((m) => rpcIdKey(m.id));
|
|
168
|
+
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));
|
|
165
169
|
const GET_STREAM_MAX = Math.max(1, Number(process.env.MCP_GET_STREAM_MAX || 64));
|
|
166
170
|
let openGetStreams = 0;
|
|
167
171
|
const streamStats = () => ({ open: openGetStreams, max: GET_STREAM_MAX, lifeMs: GET_STREAM_MS });
|
|
@@ -604,8 +608,44 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
604
608
|
? setTimeout(() => { if (res.writableEnded) return; console.error(`[mcp] tools/call open past ${MCP_CALL_WALL_MS}ms — ending the response (${inflightNameOf(req.body)})`); try { res.end(); } catch {} }, MCP_CALL_WALL_MS)
|
|
605
609
|
: null;
|
|
606
610
|
if (wall && typeof wall.unref === 'function') wall.unref();
|
|
611
|
+
// ── A REQUEST ID REUSED WHILE STILL IN FLIGHT ORPHANED THE FIRST CALL'S STREAM UNTIL THE WALL (2026-11-04) ─────
|
|
612
|
+
// The SDK routes a response to its HTTP stream through ONE per-transport map keyed by the CLIENT's JSON-RPC id
|
|
613
|
+
// (_requestToStreamMapping in webStandardStreamableHttp.js). A second POST carrying an id that is still in flight
|
|
614
|
+
// on the same session OVERWRITES that entry: the first call's result is written to the SECOND stream (which then
|
|
615
|
+
// closes), the second call's result finds no mapping and is dropped, and the first POST's stream never receives
|
|
616
|
+
// anything, so it sat open until the wall above ended it with an empty body. Measured on prod as exactly that
|
|
617
|
+
// shape: a Cursor tools/call held 600s with a sibling POST from the same client answered in milliseconds
|
|
618
|
+
// (billing_status 2026-11-03T14:22:09.334Z + .336Z; three such pairs within 3s at 07:21Z), and reproduced
|
|
619
|
+
// locally against this mount — two concurrent tools/call with id 7 → one 200 in ~0.4s, one held to the wall.
|
|
620
|
+
// The spec forbids the reuse ("MUST NOT have been previously used by the requestor within the same session"),
|
|
621
|
+
// but refusing it would fail one of the user's calls; so a colliding request WAITS for the earlier one to end
|
|
622
|
+
// and then runs normally. Both run (as they already did), each gets its own result, on its own stream.
|
|
623
|
+
// Released when the response closes as well as when handleRequest settles, so a stream ended by the wall or a
|
|
624
|
+
// client abort can never leave the id held. Pinned by tools/mcp-request-id-reuse-check.mjs.
|
|
625
|
+
const idKeys = rpcRequestIdKeys(req.body);
|
|
626
|
+
const inflight = (entry.inflight ||= new Map());
|
|
627
|
+
if (idKeys.some((k) => inflight.has(k))) {
|
|
628
|
+
console.error(`[mcp] request id ${idKeys.filter((k) => inflight.has(k)).join(',')} reused while in flight on one session (${entry.client || '?'}) — queued behind the first (${inflightNameOf(req.body)})`);
|
|
629
|
+
for (let k = idKeys.find((x) => inflight.has(x)); k; k = idKeys.find((x) => inflight.has(x))) await inflight.get(k).done;
|
|
630
|
+
if (res.writableEnded || res.destroyed) { if (wall) clearTimeout(wall); return; } // the wall or the client ended it while it waited
|
|
631
|
+
}
|
|
632
|
+
let release = () => {};
|
|
633
|
+
const rec = { res, done: new Promise((r) => { release = r; }) };
|
|
634
|
+
for (const k of idKeys) inflight.set(k, rec);
|
|
635
|
+
const free = () => { for (const k of idKeys) if (inflight.get(k) === rec) inflight.delete(k); release(); };
|
|
636
|
+
res.once('close', free);
|
|
607
637
|
try { await mcpCtx.run({ token, remote: true, client: entry.client || '', firstRun: entry.firstRun || null }, () => entry.transport.handleRequest(req, res, req.body)); }
|
|
608
|
-
finally { if (wall) clearTimeout(wall); }
|
|
638
|
+
finally { if (wall) clearTimeout(wall); free(); }
|
|
639
|
+
// ── A CANCELLED CALL'S STREAM IS ENDED, BECAUSE THE SDK WILL NEVER WRITE TO IT (2026-11-04) ──────────────────
|
|
640
|
+
// On notifications/cancelled the SDK aborts the handler and, per the spec, sends NO response for that request —
|
|
641
|
+
// and nothing else ends the SSE stream that was waiting for one, so a client that cancels without aborting its
|
|
642
|
+
// own fetch (Cursor does not abort it, by its own staff's account) held that POST open until the wall.
|
|
643
|
+
// Reproduced locally: tools/call id 9, then notifications/cancelled {requestId: 9} → the call held to the wall.
|
|
644
|
+
// The client has already given up on that id, so ending the response is what it would have done itself.
|
|
645
|
+
for (const k of rpcCancelledIdKeys(req.body)) {
|
|
646
|
+
const r = inflight.get(k);
|
|
647
|
+
if (r && !r.res.writableEnded) { console.error(`[mcp] request ${k} cancelled by the client (${entry.client || '?'}) — ending its stream`); try { r.res.end(); } catch {} }
|
|
648
|
+
}
|
|
609
649
|
});
|
|
610
650
|
|
|
611
651
|
console.error(`[mcp-remote] mounted at ${BASE || '(set HERMOSO_PUBLIC_URL)'}/mcp`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.505",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
5
|
"description": "AI marketing agent and AI ad generator, run from your own AI agent. 1182 tools. No markup on models on Starter and up. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on any plan; Free includes 10 posts a month and every paid plan posts without a limit. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads, ChatGPT Ads and AppLovin Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky, Telegram, WordPress, Discord, Slack, Whop and Google Business Profile. INSTAGRAM DM AUTOMATIONS: a keyword comment, a DM, a story reply or an ig.me link gets an automatic private reply from the brand. 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",
|