From 96207688972b88b2fbee136370eeb7dff62171c8 Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 29 Oct 2026 19:05:53 +0700 Subject: [PATCH] mcp --http: SSE keepalive for long tool calls; public App Store search shares one 20-a-minute budget --- CHANGELOG.md | 1 + cli/mcp-http.ts | 23 +++++++++++++++++++---- cli/mcp-public.ts | 12 ++++++++++++ docs/mcp.md | 3 +++ tests/mcp-http.test.ts | 21 +++++++++++++++++++++ tests/mcp-public.test.ts | 9 +++++++++ 6 files changed, 65 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0102a95..7d04989 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/cli/mcp-http.ts b/cli/mcp-http.ts index 124c3bc..3c74e13 100644 --- a/cli/mcp-http.ts +++ b/cli/mcp-http.ts @@ -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, 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, 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; diff --git a/cli/mcp-public.ts b/cli/mcp-public.ts index 81387b4..75853ba 100644 --- a/cli/mcp-public.ts +++ b/cli/mcp-public.ts @@ -190,7 +190,19 @@ export function lintListing(store: 'apple' | 'play', fields: Record= 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[] }> { + 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}`); diff --git a/docs/mcp.md b/docs/mcp.md index e78393a..142dd27 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -56,6 +56,9 @@ refused unless `--allow-origin` names it. scope; docs/credential-broker.md). They reach each tool call as `STOREBOUND_PROFILE__*` 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. diff --git a/tests/mcp-http.test.ts b/tests/mcp-http.test.ts index 7c11426..2fc8ed0 100644 --- a/tests/mcp-http.test.ts +++ b/tests/mcp-http.test.ts @@ -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); } + }); +}); diff --git a/tests/mcp-public.test.ts b/tests/mcp-public.test.ts index 2846cb8..c0b141e 100644 --- a/tests/mcp-public.test.ts +++ b/tests/mcp-public.test.ts @@ -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 = { 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); +});