Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions src/__tests__/json-rpc-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
28 changes: 26 additions & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading