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 CHANGED
@@ -429,10 +429,38 @@ export function jobWaitMs(requested, cap) {
429
429
  return Math.min(cap, Math.floor(n));
430
430
  }
431
431
 
432
- export async function pollJob(id, { intervalMs = 3000, timeoutMs = 10 * 60 * 1000, onTick } = {}) {
433
- const deadline = Date.now() + timeoutMs;
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
- const job = await getJob(id);
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
- .set('WWW-Authenticate', `Bearer resource_metadata="${BASE}/.well-known/oauth-protected-resource"`)
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
- const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
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, ''),