fix(mcp): read official registry responses to EOF instead of one chunk - #4814
Conversation
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Design Review (Fable 5) — ✅ PASSDesign-level review of The diff matches the description end-to-end: the root cause (single Design-Verdict: PASS Root-cause fix at the correct seam, bounded as before, deliberately scoped, with revert-proving hermetic tests — sound and proportionate. Suggestions
[DESIGN-REVIEWED] d224c0d |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsThe diff is a small, well-scoped fix: replacing a single Verifying the correctness claims independently:
The tests are hermetic (monkeypatch only, no filesystem or child processes). No AUTOSDE rule is weakened. Nothing survives falsification, and I find no additional grounded defect in Step 2. No findings. [OPUS-REVIEWED] d224c0d Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of I've read the contract, the intent file, and the patch, and verified the claims against the repository: the sibling single-read count (3: First-Principles-Verdict: PASS A one-hunk mechanism-level fix for a reported defect (#2222), with siblings counted, declared, and tracked rather than silently left. What this change shipsIntent: make MCP registry search in Add Server return the servers the registry actually has. FIX.
No existing mechanism does this job: Watch
[FIRST-PRINCIPLES-REVIEWED] d224c0d |
MCP server search in Add Server returned "No servers found" for queries that match entries in the official registry (aws, slack, github). _fetch_json read the body with a single resp.content.read(cap + 1). StreamReader.read(n) returns only the bytes already buffered, so on the registry's chunked HTTP/2 stream (no Content-Length) it yields the first ~8-12 KiB and json.loads fails on the truncated document. That becomes a ProviderUnavailableError, which ProviderRegistry.search swallows for fault isolation, so the dashboard renders a provider failure as an ordinary zero-result search with no error shown. Drain the response in bounded chunks until EOF, enforcing the existing size cap against the accumulated total so an oversized body is still rejected mid-stream rather than buffered whole.
348b4f3 to
d224c0d
Compare
|
Dispositions for the First Principles CONCERNS verdict on
|
bolichen97
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (2 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: fix(mcp) — streams the official-registry response to EOF via bounded iter_chunked instead of a single read that truncated multi-chunk pages; the _MAX_RESPONSE_BYTES cap is preserved (checked against the accumulated total), no new parsing introduced.
bolichen97
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (2 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: fix — stream the official MCP registry response to EOF in bounded 64KiB chunks so a multi-chunk search page parses whole instead of truncated; the _MAX_RESPONSE_BYTES cap is preserved and enforced mid-stream.
iamwhatever
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (2 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean (Semgrep + CodeQL success, 0 alerts), security checklist all-NO, AI reviewers green. Category: fix(mcp) stream the official-registry response to EOF in bounded chunks instead of a single read(n) that truncated multi-chunk documents; the _MAX_RESPONSE_BYTES cap is preserved and now enforced against the accumulated total. Clear root cause, source + tests only.
…nk (#4829) (#4858) Three dashboard HTTP readers assumed a single StreamReader.read(cap + 1) returns the whole response body. read(n) returns UP TO n bytes, resolving as soon as any data is buffered, so on a chunked response with no Content-Length it returns only the first buffered chunk (~8-12 KiB) and the caller silently works on a truncated body: - updates._fetch_feed_bytes: release feed parsed from a partial document, so an update check can misread or fail on a chunked feed. The comment claiming an oversized body "is DETECTED rather than silently truncated" was false for a chunked feed and is corrected. - feedback._read_capped_text: a truncated Aperture response was decoded and treated as the full reply. - source_providers Jira fetch: json.loads on a partial document surfaced as SourceProviderError, so the issue read as unavailable. Same defect class as the official-registry site fixed in #4814. Since the single-read shape has been reintroduced independently more than once, extract one shared read_capped_response(resp, cap) helper in the dashboard handlers' _shared module (next to the request-side read_bounded_json) that drains iter_chunked chunks to EOF, enforcing the cap against the accumulated total so an oversized body is refused mid-stream, and clamping the return to cap + 1 bytes so every caller keeps its existing over-cap sentinel unchanged. All three sites route through it; cap values and caller semantics are untouched. Tests lock in: a multi-chunk body is read whole at the helper and at all three call sites (proven red against the previous single-read code), an over-cap body still trips each existing cap-exceeded path, reading stops mid-stream rather than buffering an oversized body, and an exact-cap body arrives complete. Closes #4829 Co-authored-by: Joe Guo <zejiangg@amazon.com>
kirodotdev#4814) MCP server search in Add Server returned "No servers found" for queries that match entries in the official registry (aws, slack, github). _fetch_json read the body with a single resp.content.read(cap + 1). StreamReader.read(n) returns only the bytes already buffered, so on the registry's chunked HTTP/2 stream (no Content-Length) it yields the first ~8-12 KiB and json.loads fails on the truncated document. That becomes a ProviderUnavailableError, which ProviderRegistry.search swallows for fault isolation, so the dashboard renders a provider failure as an ordinary zero-result search with no error shown. Drain the response in bounded chunks until EOF, enforcing the existing size cap against the accumulated total so an oversized body is still rejected mid-stream rather than buffered whole.
…nk (kirodotdev#4829) (kirodotdev#4858) Three dashboard HTTP readers assumed a single StreamReader.read(cap + 1) returns the whole response body. read(n) returns UP TO n bytes, resolving as soon as any data is buffered, so on a chunked response with no Content-Length it returns only the first buffered chunk (~8-12 KiB) and the caller silently works on a truncated body: - updates._fetch_feed_bytes: release feed parsed from a partial document, so an update check can misread or fail on a chunked feed. The comment claiming an oversized body "is DETECTED rather than silently truncated" was false for a chunked feed and is corrected. - feedback._read_capped_text: a truncated Aperture response was decoded and treated as the full reply. - source_providers Jira fetch: json.loads on a partial document surfaced as SourceProviderError, so the issue read as unavailable. Same defect class as the official-registry site fixed in kirodotdev#4814. Since the single-read shape has been reintroduced independently more than once, extract one shared read_capped_response(resp, cap) helper in the dashboard handlers' _shared module (next to the request-side read_bounded_json) that drains iter_chunked chunks to EOF, enforcing the cap against the accumulated total so an oversized body is refused mid-stream, and clamping the return to cap + 1 bytes so every caller keeps its existing over-cap sentinel unchanged. All three sites route through it; cap values and caller semantics are untouched. Tests lock in: a multi-chunk body is read whole at the helper and at all three call sites (proven red against the previous single-read code), an over-cap body still trips each existing cap-exceeded path, reading stops mid-stream rather than buffering an oversized body, and an exact-cap body arrives complete. Closes kirodotdev#4829 Co-authored-by: Joe Guo <zejiangg@amazon.com>
Problem / Motivation
Searching for MCP servers in Agent Capabilities -> Connections -> MCP Servers -> Add Server returns
No servers foundfor queries that do match entries in the official registry.aws,slackandgithuball come back empty while a direct request to the same endpoint returns 17+ entries, and the UI shows no error -- a provider failure is indistinguishable from a genuinely empty result.The failure depends on how the response is chunked, not on the query text: a zero-hit query returns a small body that fits in one chunk and parses fine, which is why the feature looks like it works until you search for something popular.
Why it matters
Add Server is the only in-product path to the official MCP registry, so this makes registry discovery unusable for exactly the queries a user is most likely to type. Because
ProviderRegistry.search()isolates provider faults by design, the failure is silent: the user concludes the registry has noawsserver rather than that the lookup broke, and nothing in the UI suggests otherwise. The only trace is aWARNINGin the gateway log.What changed (motivation → approach → change)
_fetch_json()read the whole body with a single call:aiohttp.StreamReader.read(n)returns up tonbytes -- it resolves as soon as any data is buffered rather than waiting for EOF. The registry streams search pages with noContent-Length, so the body arrives in several chunks and that single call returns only the first one (~8-12 KiB here).json.loadsthen fails withUnterminated string,_fetch_jsonconverts theJSONDecodeErrorintoProviderUnavailableError, andProviderRegistry.search()catches it for fault isolation and returns an empty list for that provider -- which the dashboard renders as an ordinary 0-result search.The fix streams the response to EOF in bounded chunks via
resp.content.iter_chunked()-- the mechanism already used at nine sites in this repo -- and enforces the existing size cap against the accumulated total, so an oversized body is still refused mid-stream rather than buffered whole. The 404 ->Noneand transport-failure ->ProviderUnavailableErrorcontracts are unchanged.Scope is deliberately limited to the reported defect. The same single-read shape survives in three unrelated readers (
updates.py:740,feedback.py:179,source_providers.py:2422); each has its own reachability and consequence argument, and folding them in here was already tried and rejected on this code (#2224'supdates.pyhunk was asked to be reverted as an undeclared change). They are tracked in #4829.Tests
Six tests in a new
TestFetchJsonReadclass (test/test_mcp_providers.py), all hermetic -- a fakeStreamReaderhands out queued chunks and honours the documented "up to n bytes" contract:test_multi_chunk_body_is_assembled-- the regression: a document split in three parses whole.test_heavily_fragmented_body_is_assembled-- byte-at-a-time delivery still assembles.test_oversized_body_rejected_mid_stream-- the cap applies to the accumulated total, and the read aborts instead of draining the rest.test_body_at_exact_cap_is_accepted-- the boundary is not off by one.test_404_returns_none_without_reading-- a missing entry stays distinct from an unreachable registry.test_truncated_body_surfaces_as_provider_unavailable-- a genuinely malformed document is still a provider failure, not a crash.Reverting the production hunk fails the first four by name; the last two are contract-preservation tests and correctly stay green either way. The test double models
iter_chunkedand deliberately also keepsread(n), so a single-read implementation is still exercised and the revert proof stays non-vacuous.Manual verification
Reproduced and re-verified through three probes, before and after the change:
_fetch_jsonagainst the live registry (search=aws&limit=20, 28,858 bytes, noContent-Length)ProviderUnavailableError: Unterminated string starting at ... char 7937_fetch_jsonagainst a local chunked-stream server (53 KB in 8 KiB chunks)Unterminated string ... char 8126GET /api/mcp/discover?q=<q>&limit=20, the request the Add Server dialog issues{"results": [], "providers": ["official"]}, withWARNING MCP provider official failed for query 'aws'and the traceback in the logaws20,slack18,github20 resultsRelated Issues
Fixes #2222
Same defect as #2224 (open, currently conflicting) and #2232 (closed as a duplicate of it); this change is scoped to
_fetch_jsonand carries the regression tests. Credit to @ayahiro1729 for the report and the diagnosis, and to @ChickenisLegit for independently finding it.Why no screenshot: backend-only change; the diff touches no frontend path and renders no new UI -- the user-visible effect is the existing Add Server list going from empty to populated, evidenced by the API probe above.
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)