hermoso 0.1.308 → 0.1.320
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/client.mjs +31 -3
- package/mcp/http.mjs +44 -3
- package/mcp/tools.mjs +171 -68
- package/package.json +1 -1
- package/skills/hermoso-marketing/SKILL.md +1 -1
package/mcp/client.mjs
CHANGED
|
@@ -429,10 +429,38 @@ export function jobWaitMs(requested, cap) {
|
|
|
429
429
|
return Math.min(cap, Math.floor(n));
|
|
430
430
|
}
|
|
431
431
|
|
|
432
|
-
|
|
433
|
-
|
|
432
|
+
// A 404 IN THE SECONDS AFTER A SUBMIT IS NOT "NO SUCH JOB" (journey QA 2026-09-25). edit_video queued a render on the
|
|
433
|
+
// revision a deploy was retiring; the poll 3s later reached the new revision, which had never heard of it, and the tool
|
|
434
|
+
// answered "Error: No such job" about a real render that held credits. The server now reads through to the durable job
|
|
435
|
+
// mirror before a 404 (lib/job-readthrough.mjs), and this is the client half: inside a short grace from the submit a
|
|
436
|
+
// 404 (and a 502/503/504, which is what a rollover looks like from outside) is retried; past it a 404 is final and is
|
|
437
|
+
// said in words, never as the bare route error. PURE, so tools/job-readthrough-check.mjs runs it.
|
|
438
|
+
export const JOB_MISS_GRACE_MS = 30_000;
|
|
439
|
+
export function pollMissVerdict(status, { startedAt, now = Date.now(), deadline = Infinity, graceMs = JOB_MISS_GRACE_MS } = {}) {
|
|
440
|
+
const st = Number(status);
|
|
441
|
+
if (st === 404) return (now - startedAt < graceMs && now < deadline) ? 'retry' : 'final';
|
|
442
|
+
if ((st === 502 || st === 503 || st === 504) && now < deadline) return 'retry';
|
|
443
|
+
return 'throw';
|
|
444
|
+
}
|
|
445
|
+
export function jobMissMessage(id) {
|
|
446
|
+
return `Hermoso has no record of job ${id} on this workspace, after checking the live queue and the durable job store for ${Math.round(JOB_MISS_GRACE_MS / 1000)}s. `
|
|
447
|
+
+ 'If it was just submitted, the server that took it was replaced before it wrote the job down, so it cannot be followed from here. A render lost that way is not charged: the credits held for it are released automatically. '
|
|
448
|
+
+ 'Call list_jobs to see this workspace\'s recent jobs. Do not re-run the render on the strength of this message alone.';
|
|
449
|
+
}
|
|
450
|
+
export async function pollJob(id, { intervalMs = 3000, timeoutMs = 10 * 60 * 1000, onTick, getJobFn = getJob } = {}) {
|
|
451
|
+
const startedAt = Date.now();
|
|
452
|
+
const deadline = startedAt + timeoutMs;
|
|
434
453
|
for (;;) {
|
|
435
|
-
|
|
454
|
+
let job;
|
|
455
|
+
try { job = await getJobFn(id); }
|
|
456
|
+
catch (e) {
|
|
457
|
+
const v = pollMissVerdict(e?.status, { startedAt, now: Date.now(), deadline });
|
|
458
|
+
if (v === 'final') throw Object.assign(new Error(jobMissMessage(id)), { status: 404, _viaApi: true, _jobMissing: true });
|
|
459
|
+
if (v === 'throw') throw e;
|
|
460
|
+
await new Promise(r => setTimeout(r, Math.min(intervalMs, Math.max(50, deadline - Date.now()))));
|
|
461
|
+
if (Date.now() > deadline) throw Object.assign(new Error('Render timed out — check `hermoso jobs get ' + id + '`'), { jobId: id });
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
436
464
|
onTick?.(job);
|
|
437
465
|
if (job.status === 'done') return { job, result: jobResult(job) };
|
|
438
466
|
// A FAILED JOB HAS ALREADY BEEN RECORDED BY THE SERVER (2026-09-21). The job runner files the worker's real error in
|
package/mcp/http.mjs
CHANGED
|
@@ -72,6 +72,42 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl, onSessionStar
|
|
|
72
72
|
app.get('/.well-known/oauth-protected-resource', protectedResourceMetadata);
|
|
73
73
|
app.get(`/.well-known/oauth-protected-resource${MCP_PATH}`, protectedResourceMetadata);
|
|
74
74
|
|
|
75
|
+
// ── THE STATIC SERVER CARD A DIRECTORY READS INSTEAD OF SCANNING (2026-09-25) ─────────────────────────────────────
|
|
76
|
+
// Smithery's re-scan stopped at "Authentication required": its first probe (UA `SmitheryBot/1.0 (+https://…)`) gets
|
|
77
|
+
// the anonymous preview, but its connect step sends NO user-agent, and a UA-less tokenless handshake is exactly
|
|
78
|
+
// Grok's setup probe, which MUST stay challenged (a 200 there made Grok save us as a no-auth connector). Smithery's
|
|
79
|
+
// own answer for an OAuth server is this document (smithery.ai/docs/build/publish, read 2026-09-25: "you can bypass
|
|
80
|
+
// scanning by serving metadata manually at /.well-known/mcp/server-card.json" — serverInfo, authentication, tools,
|
|
81
|
+
// resources, prompts, SEP-1649 shapes). So the roster a crawler would have listed anonymously is published here,
|
|
82
|
+
// built from the SAME registerTools call the anonymous preview makes and read back through a real MCP client, so
|
|
83
|
+
// it cannot drift from what tools/list serves. Built once per process (the roster is static per process).
|
|
84
|
+
let cardPromise = null;
|
|
85
|
+
const buildServerCard = async () => {
|
|
86
|
+
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
|
|
87
|
+
const { InMemoryTransport } = await import('@modelcontextprotocol/sdk/inMemory.js');
|
|
88
|
+
const server = new McpServer({ name: 'hermoso', version: PKG_VERSION }, { instructions: MCP_INSTRUCTIONS });
|
|
89
|
+
registerTools(server, { only: [...DEFAULT_TOOL_GROUPS], directory: false, widgetHost: false, hosted: true });
|
|
90
|
+
const [a, b] = InMemoryTransport.createLinkedPair();
|
|
91
|
+
const client = new Client({ name: 'server-card', version: '1' });
|
|
92
|
+
await Promise.all([server.connect(a), client.connect(b)]);
|
|
93
|
+
try {
|
|
94
|
+
const list = async (fn, key) => { const out = []; let cursor; do { const r = await fn(cursor ? { cursor } : {}).catch(() => null); if (!r) break; out.push(...(r[key] || [])); cursor = r.nextCursor; } while (cursor); return out; };
|
|
95
|
+
const tools = await list((p) => client.listTools(p), 'tools');
|
|
96
|
+
const resources = await list((p) => client.listResources(p), 'resources');
|
|
97
|
+
const prompts = await list((p) => client.listPrompts(p), 'prompts');
|
|
98
|
+
return { serverInfo: { name: 'hermoso', title: 'Hermoso', version: PKG_VERSION }, instructions: MCP_INSTRUCTIONS,
|
|
99
|
+
authentication: { required: true, schemes: ['oauth2', 'bearer'] },
|
|
100
|
+
transport: { type: 'streamable-http', url: `${BASE}${MCP_PATH}` },
|
|
101
|
+
tools, resources, prompts };
|
|
102
|
+
} finally { try { await client.close(); } catch {} try { await server.close(); } catch {} }
|
|
103
|
+
};
|
|
104
|
+
app.get('/.well-known/mcp/server-card.json', async (req, res) => {
|
|
105
|
+
try {
|
|
106
|
+
cardPromise ||= buildServerCard().catch((e) => { cardPromise = null; throw e; });
|
|
107
|
+
res.set('Cache-Control', 'public, max-age=3600').json(await cardPromise);
|
|
108
|
+
} catch (e) { res.status(503).json({ error: 'server card unavailable, try again', detail: String(e?.message || e).slice(0, 200) }); }
|
|
109
|
+
});
|
|
110
|
+
|
|
75
111
|
// Per-session Streamable-HTTP transports. Each authenticated session gets its own McpServer with the same tools.
|
|
76
112
|
//
|
|
77
113
|
// ── A SESSION WAS EXPENSIVE, AND THIS MAP IS WHY PROD OOM'd (2026-08-01, again 2026-08-24) ───────────────────
|
|
@@ -132,7 +168,10 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
132
168
|
// then the two that need no browser at all. `WWW-Authenticate` still points at the protected-resource metadata,
|
|
133
169
|
// which is what a spec-following client uses; this is for the one that is not following it.
|
|
134
170
|
const challenge = (res) => res.status(401)
|
|
135
|
-
|
|
171
|
+
// `scope` rides the challenge (MCP authorization spec, "Protected Resource Metadata Discovery Requirements":
|
|
172
|
+
// servers SHOULD include it; ChatGPT's own auth doc shows the same shape). The same ONE list the PRM and the AS
|
|
173
|
+
// metadata publish, so a client that scopes its authorize request from the challenge asks for exactly that.
|
|
174
|
+
.set('WWW-Authenticate', `Bearer resource_metadata="${BASE}/.well-known/oauth-protected-resource", scope="hermoso.research hermoso.generate"`)
|
|
136
175
|
.json({
|
|
137
176
|
error: 'Authentication required',
|
|
138
177
|
error_description: 'This Hermoso MCP server needs a signed-in account. Normally your client opens a browser consent page. IF NO BROWSER OR CONSENT CARD OPENED, your client cannot complete OAuth — retrying will keep failing the same way. Read the `how_to_connect` field of THIS response and use one of those two browser-free routes instead. Tell the user which one you are taking.',
|
|
@@ -341,7 +380,9 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
341
380
|
|
|
342
381
|
app.all(MCP_PATH, async (req, res) => {
|
|
343
382
|
const auth = req.headers.authorization || '';
|
|
344
|
-
|
|
383
|
+
// The auth-scheme name is case-insensitive (RFC 9110 §11.1, RFC 6750 §2.1): `bearer <key>` is the same
|
|
384
|
+
// credential, and reading only `Bearer ` made a client that lower-cases it look tokenless, challenged for ever.
|
|
385
|
+
const token = (/^Bearer[ \t]+(\S+)[ \t]*$/i.exec(auth) || [])[1] || '';
|
|
345
386
|
// A HANDSHAKE IS NOT USE. `verifyBearer` stamps the key's last_used_at, and the admin dashboard's "last
|
|
346
387
|
// active" takes the max of that, the billed ledger and the user's last_seen — so an agent that merely holds a
|
|
347
388
|
// connection open (initialize, tools/list, ping, a notification) kept reporting the account as ACTIVE while
|
|
@@ -420,7 +461,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
420
461
|
// connectedProviders() ([[failed-read-is-not-empty]]).
|
|
421
462
|
const connectors = await mcpCtx.run({ token, remote: true, client: rememberedClient(req) }, () => connectedProviders());
|
|
422
463
|
const server = new McpServer({ name: 'hermoso', version: PKG_VERSION }, { instructions: MCP_INSTRUCTIONS });
|
|
423
|
-
registerTools(server, { only: scope.groups, directory: scope.directory || false, connectors, widgetHost: isWidgetHost(entry?.client || clientInfoOf(req.body), req) , hosted: true, client: entry?.client || rememberedClient(req) }); // the SAME tools as stdio (minus any the caller scoped out) — and every /api call they make carries this user's token
|
|
464
|
+
registerTools(server, { only: scope.groups, directory: scope.directory || false, connectors, widgetHost: isWidgetHost(entry?.client || clientInfoOf(req.body), req) , hosted: true, client: entry?.client || rememberedClient(req), ua: String(req.headers['user-agent'] || '').slice(0, 120) }); // the SAME tools as stdio (minus any the caller scoped out) — and every /api call they make carries this user's token
|
|
424
465
|
const transport = new StreamableHTTPServerTransport({
|
|
425
466
|
// CSPRNG, per the spec's SHOULD for session ids (Math.random() is not one).
|
|
426
467
|
sessionIdGenerator: () => 'sess_' + randomUUID().replace(/-/g, ''),
|