mcp --http: SSE keepalive for long tool calls; public App Store search shares one 20-a-minute budget
This commit is contained in:
parent
c500866295
commit
9620768897
6 changed files with 65 additions and 4 deletions
|
|
@ -37,6 +37,9 @@ import { runCommand } from './lib/runner';
|
|||
export const DEFAULT_PORT = 7787;
|
||||
export const PUBLIC_PORT = 7788;
|
||||
const MAX_BODY = 4 * 1024 * 1024;
|
||||
/** SSE comment line interval while a tool call runs (proxies cut silent responses). */
|
||||
export let SSE_KEEPALIVE_MS = 20_000;
|
||||
export const setKeepaliveMs = (ms: number): void => { SSE_KEEPALIVE_MS = ms; };
|
||||
/** Public mode: one JSON-RPC message of a read-only tool never needs more. */
|
||||
export const PUBLIC_MAX_BODY = 64 * 1024;
|
||||
export const PUBLIC_DEFAULT_RATE = 60;
|
||||
|
|
@ -213,10 +216,22 @@ export function startHttp(o: HttpOptions): Promise<{ close: () => void; port: nu
|
|||
let msg: unknown;
|
||||
try { msg = JSON.parse(body); } catch { return send(res, 400, { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }); }
|
||||
if (Array.isArray(msg) || typeof msg !== 'object' || msg === null) return send(res, 400, { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'One JSON-RPC message per request' } });
|
||||
try {
|
||||
const r = pub ? await handle(msg as never, noRun, pub.surface) : await answer(msg as Record<string, unknown>, o);
|
||||
return r ? send(res, 200, r) : send(res, 202);
|
||||
} catch (e) { return send(res, 500, { jsonrpc: '2.0', id: (msg as { id?: unknown }).id ?? null, error: { code: -32603, message: String((e as Error).message) } }); }
|
||||
const work = (async () => {
|
||||
try { return pub ? await handle(msg as never, noRun, pub.surface) : await answer(msg as Record<string, unknown>, o); }
|
||||
catch (e) { return { jsonrpc: '2.0', id: (msg as { id?: unknown }).id ?? null, error: { code: -32603, message: String((e as Error).message) } }; }
|
||||
})();
|
||||
// A tool call may run for minutes; a proxy (Cloudflare: 100 s) cuts a response that sends nothing. A client that
|
||||
// accepts SSE gets the answer as one SSE event with a comment line every 20 s until then.
|
||||
if ((msg as { method?: unknown }).method === 'tools/call' && /text\/event-stream/.test(String(req.headers.accept ?? ''))) {
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', 'x-accel-buffering': 'no' });
|
||||
res.write(': started\n\n');
|
||||
const beat = setInterval(() => res.write(': keepalive\n\n'), SSE_KEEPALIVE_MS);
|
||||
const r = await work.finally(() => clearInterval(beat));
|
||||
res.end(`event: message\ndata: ${JSON.stringify(r)}\n\n`);
|
||||
return;
|
||||
}
|
||||
const r = await work;
|
||||
return r ? send(res, 200, r) : send(res, 202);
|
||||
});
|
||||
// a tool call can run for many minutes (check on a big project, uploads): Node's 300 s request timeout would cut it
|
||||
server.requestTimeout = 0;
|
||||
|
|
|
|||
|
|
@ -190,7 +190,19 @@ export function lintListing(store: 'apple' | 'play', fields: Record<string, unkn
|
|||
|
||||
interface ItunesApp { trackId?: number; trackName?: string; sellerName?: string; averageUserRating?: number; userRatingCount?: number; primaryGenreName?: string }
|
||||
|
||||
// Apple allows about 20 search calls a minute per IP: one shared budget for the whole server, whoever asks
|
||||
const SEARCH_PER_MIN = 20;
|
||||
const searchTimes: number[] = [];
|
||||
export function searchBudget(now = Date.now()): { ok: true } | { ok: false; retryAfter: number } {
|
||||
while (searchTimes.length && searchTimes[0]! <= now - 60_000) searchTimes.shift();
|
||||
if (searchTimes.length >= SEARCH_PER_MIN) return { ok: false, retryAfter: Math.ceil((searchTimes[0]! + 60_000 - now) / 1000) };
|
||||
searchTimes.push(now);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function searchApps(term: string, country: string, limit: number, base: string, f: typeof fetch): Promise<{ results: Record<string, unknown>[] }> {
|
||||
const b = searchBudget();
|
||||
if (!b.ok) throw new Error(`the App Store search is busy (Apple allows ${SEARCH_PER_MIN} calls a minute for this server): try again in ${b.retryAfter} s`);
|
||||
const url = `${base}${base.includes('?') ? '&' : '?'}${new URLSearchParams({ term, country: country.toLowerCase(), media: 'software', entity: 'software', limit: String(limit) })}`;
|
||||
const r = await f(url, { signal: AbortSignal.timeout(10_000), headers: { accept: 'application/json' } });
|
||||
if (!r.ok) throw new Error(`the App Store search answered ${r.status}`);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue