diff --git a/src/__tests__/json-rpc-integration.test.ts b/src/__tests__/json-rpc-integration.test.ts index 26a3dcf7..a893be5d 100644 --- a/src/__tests__/json-rpc-integration.test.ts +++ b/src/__tests__/json-rpc-integration.test.ts @@ -402,4 +402,65 @@ describe('JSON RPC Integration Tests', () => { expect(response.status).toBeLessThan(500); }); }); + + describe('2026-07-28 subscriptions/listen', () => { + // DBHub never emits change notifications (a fresh stateless server is + // built per request), yet the SDK advertises tools.listChanged, so + // modern clients open subscriptions/listen right after discovery and + // wait on it before listing tools. The handler must refuse the listen + // in-band at once rather than hold the POST open as an empty stream: + // behind a buffering proxy (AWS API Gateway cuts at 30s with a 503) a + // held-open listen delays tool discovery by the proxy timeout. See + // https://github.com/bytebase/dbhub/issues/474. + it('refuses subscriptions/listen in-band instead of holding the request open', async () => { + const logLengthBefore = startupLogs.length; + const controller = new AbortController(); + const abortTimer = setTimeout(() => controller.abort(), 5000); + let response: Response; + try { + response = await fetch(`${baseUrl}/mcp`, { + method: 'POST', + signal: controller.signal, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'Mcp-Protocol-Version': '2026-07-28', + 'Mcp-Method': 'subscriptions/listen', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 'listen-1', + method: 'subscriptions/listen', + params: { + notifications: { tools: true }, + _meta: { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientCapabilities': {}, + }, + }, + }), + }); + } finally { + clearTimeout(abortTimer); + } + + // In-band refusal: HTTP 200 carrying a JSON-RPC error. Without the + // limit the handler instead answers with a text/event-stream it holds + // open until the client gives up (the abort above). + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('application/json'); + const body = await response.json(); + expect(body).toMatchObject({ + jsonrpc: '2.0', + id: 'listen-1', + error: { code: -32603, message: 'Subscription limit reached' }, + }); + + // The SDK reports each refusal through onerror; with the limit at 0 + // that is the configured answer, so it must not land in the log. + await new Promise((resolve) => setTimeout(resolve, 100)); + const newLogs = startupLogs.slice(logLengthBefore).join(''); + expect(newLogs).not.toContain('subscriptions/listen refused'); + }); + }); }); \ No newline at end of file diff --git a/src/server.ts b/src/server.ts index c84c2db1..8c110bbe 100644 --- a/src/server.ts +++ b/src/server.ts @@ -38,9 +38,30 @@ export const SERVER_VERSION = packageJson.version; // cached tool list can be after a reload. const TOOLS_LIST_CACHE_TTL_MS = 300_000; +// DBHub never emits change notifications over HTTP: createMcpHandler builds +// a fresh stateless server per request, so no instance outlives the request +// that would have to carry a `notifications/tools/list_changed`. Yet the SDK +// advertises `tools.listChanged` once tools are registered, and 2026-07-28 +// clients then open `subscriptions/listen`, a POST the handler holds open as +// a notification stream that never carries anything. Behind a buffering +// proxy (e.g. AWS API Gateway, which cuts at 30s with a 503) that held-open +// request delays tool discovery by the proxy timeout and shows up as a +// steady stream of gateway errors. A limit of 0 makes the SDK refuse the +// listen in-band (JSON-RPC -32603, HTTP 200) so clients fall back to plain +// polling immediately. Revisit if list-changed notifications are ever wired +// up for TOML hot reload. See https://github.com/bytebase/dbhub/issues/474. +const MAX_HTTP_SUBSCRIPTIONS = 0; + +// The SDK reports every refused `subscriptions/listen` through `onerror` +// with this message. With the limit at 0 that is the configured answer, not +// a failure, so it is not worth an error line per client connect. +const SUBSCRIPTION_REFUSED_PREFIX = "subscriptions/listen refused: subscription limit reached"; + /** Per-surface error logger for the MCP serving entries. */ -const logMcpError = (scope: string) => (error: Error) => +const logMcpError = (scope: string) => (error: Error) => { + if (error.message.startsWith(SUBSCRIPTION_REFUSED_PREFIX)) return; console.error(`MCP ${scope} error:`, error); +}; /** * Generate ASCII art banner with version information @@ -277,7 +298,10 @@ See documentation for more details on configuring database connections. // the same per-request idiom DBHub hand-wired before. It also validates // the 2026-07-28 standard headers (Mcp-Method / Mcp-Name) against the // body on the modern path. - const mcpHandler = createMcpHandler(createServer, { onerror: logMcpError("handler") }); + const mcpHandler = createMcpHandler(createServer, { + maxSubscriptions: MAX_HTTP_SUBSCRIPTIONS, + onerror: logMcpError("handler"), + }); const mcpNodeHandler = toNodeHandler(mcpHandler, { onerror: logMcpError("adapter") }); // express.json() has already consumed the request stream, so hand the