mcp --http: SSE keepalive for long tool calls; public App Store search shares one 20-a-minute budget

This commit is contained in:
CRTkafa 2026-10-29 19:05:53 +07:00
parent c500866295
commit 9620768897
6 changed files with 65 additions and 4 deletions

View file

@ -3,6 +3,7 @@
## Unreleased
- `mcp --http`: a `tools/call` that accepts `text/event-stream` gets SSE keepalive comments every 20 s and then its result, so long calls survive proxies that cut silent responses. The public server's App Store search keeps one budget of 20 calls a minute (Apple's limit).
- `storebound mcp --http --public`: a public, key-less MCP server (no sign-in, no store credentials, no project folders, no child processes, read-only). Tools: `skills_list`, `skill_get`, `pitfalls_search`, `store_limits`, `listing_lint` and `aso_search` (the public iTunes Search API, the only network call). Rate limit per client (`--rate`, 429 with Retry-After; `--trust-proxy` for X-Forwarded-For), 64 KB request bodies, CORS preflight. docs/mcp.md.
## 0.8.58 · 2026-10-29

View file

@ -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;

View file

@ -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}`);

View file

@ -56,6 +56,9 @@ refused unless `--allow-origin` names it.
scope; docs/credential-broker.md). They reach each tool call as `STOREBOUND_PROFILE_<NAME>_*` env, held in memory for
10 minutes and never written. A project picks its profile as usual (the config's `account`).
- `project` may also be a folder name under the roots (a shared server keeps its checkouts there).
- Long calls: a `tools/call` whose request accepts `text/event-stream` is answered as SSE, with a comment line every
20 s until the result arrives as one `message` event, so a proxy that cuts silent responses (Cloudflare: 100 s)
keeps the connection. Other requests get plain JSON.
- `/health` answers `{ ok, version }` without auth.

View file

@ -138,3 +138,24 @@ describe('read-only and broker profiles', () => {
} finally { srv.close(); }
});
});
describe('long calls over SSE', () => {
test('a tools/call that accepts text/event-stream gets keepalive comments, then the answer as one event', async () => {
const { setKeepaliveMs } = await import('../cli/mcp-http');
setKeepaliveMs(50);
const s = await startHttp({ host: '127.0.0.1', port: 0, roots: [root], project: app, allowOrigins: [], auth: { kind: 'token', token: 'b'.repeat(64) } });
try {
const r = await fetch(`http://127.0.0.1:${s.port}/mcp`, { method: 'POST',
headers: { authorization: `Bearer ${'b'.repeat(64)}`, 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
body: JSON.stringify({ jsonrpc: '2.0', id: 9, method: 'tools/call', params: { name: 'check', arguments: { project: '/nope' } } }) });
expect(r.headers.get('content-type')).toBe('text/event-stream');
const text = await r.text();
expect(text.startsWith(': started')).toBe(true);
const data = /event: message\ndata: (.*)\n\n$/.exec(text)!;
expect(JSON.parse(data[1]!)).toMatchObject({ id: 9, result: { isError: true } });
// JSON for everything else
const p = await fetch(`http://127.0.0.1:${s.port}/mcp`, { method: 'POST', headers: { authorization: `Bearer ${'b'.repeat(64)}`, 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' }) });
expect(p.headers.get('content-type')).toBe('application/json');
} finally { s.close(); setKeepaliveMs(20_000); }
});
});

View file

@ -268,3 +268,12 @@ describe('http, public mode', () => {
expect(() => startHttp({ ...base })).toThrow('only for --public');
});
});
test('App Store search keeps one shared budget of 20 calls a minute', async () => {
const { searchBudget } = await import('../cli/mcp-public');
const t0 = Date.now() + 3_600_000; // after any real call earlier in this file
let last: ReturnType<typeof searchBudget> = { ok: true };
for (let i = 0; i < 25; i++) last = searchBudget(t0 + i);
expect(last.ok).toBe(false);
expect(searchBudget(t0 + 61_000).ok).toBe(true);
});