modelcontextprotocol / modelcontextprotocol/typescript-sdk
[v2] versionNegotiation: 2xx server/discover response with an empty or unparseable body fails connect() instead of falling back to legacy initialize
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 13.4k
- Forks
- 2.2k
- Avg merge
- 3d 15h
- Merged PRs (30d)
- 4
Description
What happened?
With versionNegotiation: { mode: 'auto' } over StreamableHTTPClientTransport, the version-negotiation probe handles HTTP rejections and HTTP successes asymmetrically:
- A non-2xx probe answer an older server might produce (e.g. a plain 400 with an unrecognized body) is classified conservatively:
classifyHttpErrorfalls back to the legacyinitializehandshake. 👍 - A 2xx probe answer with an empty or unparseable body (empty 200, whitespace body, bare 204, or a 2xx with a wrong/missing
content-type) rejectsconnect()withSdkError(SdkErrorCode.EraNegotiationFailed)— no legacy fallback is attempted.
So a server or intermediary (reverse proxy, API gateway, middlebox) that swallows the unrecognized server/discover POST into an empty 2xx bricks the connection, while one that rejects it with a 4xx degrades gracefully. The empty-2xx shape is at least as strong evidence of "this endpoint doesn't speak the discover protocol" as an unparseable 4xx, but it gets a hard failure instead of the fallback.
We hit this integrating the 2.0 client in a host application, where an intermediary answering the probe with an empty 2xx turned a working legacy connection into a hard connect failure.
Mechanism (2.0.0 sources)
src/client/streamableHttp.ts— for a request answered 2xx withcontent-type: application/json,_senddoesawait response.json(); an empty/whitespace body makes that throwSyntaxError, rejectingsend(). A 2xx with a missing or non-JSON/non-SSEcontent-type(e.g. a bare 204) instead throwsSdkError(SdkErrorCode.ClientHttpUnexpectedContent). Neither is anSdkHttpError(those are only constructed for non-ok statuses).src/client/versionNegotiation.ts—normalizeReplysees thesend-errorreply; it is neither an auth-seam escape nor anSdkHttpError, so it lands in the catch-all:{ kind: 'network-error', error }. The "server answered 2xx and the answer was unusable" information is lost here — it is conflated with a genuine network outage.src/client/probeClassifier.ts—classifyNetworkErrorfalls back to legacy only for a browser opaque fetchTypeError(the CORS-preflight carve-out). Everything else — including theSyntaxErrorandClientHttpUnexpectedContentshapes above — becomesSdkError(SdkErrorCode.EraNegotiationFailed, 'Version negotiation probe failed: …', { cause }), andconnect()rejects.
By contrast, the http-error row (classifyHttpError) ends with: unparseable/unrecognized non-auth 4xx → conservative legacy fallback. That is the asymmetry.
What did you expect?
An unusable 2xx probe answer should degrade to the legacy initialize handshake, the same way an unparseable non-auth 4xx does.
Suggested direction
Preserve the "the server answered 2xx but the answer was unusable" distinction instead of folding it into network-error. E.g. in normalizeReply, give the probe's 2xx parse failures their own outcome kind (or route them into the http-error row with the real status), and classify that kind the way the unparseable-4xx row is classified: conservative legacy fallback when fallbackAvailable, a typed error otherwise. Genuine network failures (DNS, connection reset, CORS) keep today's typed-error behavior, and the auth/5xx rows are unaffected — the fallback would be scoped to "the HTTP layer succeeded, the discover answer is unusable".
Code to reproduce
Any of these bodies on the server/discover probe POST reproduces it (verified against 2.0.0-beta.5 with an injected fetch stub; the relevant code paths are unchanged in the published 2.0.0 sources):
| Probe answer | Result |
|---|---|
200, content-type: application/json, empty body |
❌ EraNegotiationFailed (cause: SyntaxError) |
200, content-type: application/json, whitespace body |
❌ EraNegotiationFailed (cause: SyntaxError) |
204, no content-type |
❌ EraNegotiationFailed (cause: ClientHttpUnexpectedContent) |
200, content-type: text/plain |
❌ EraNegotiationFailed (cause: ClientHttpUnexpectedContent) |
400, unrecognized body |
✅ legacy initialize fallback |
Sketch:
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
const fetchStub: typeof fetch = async (input, init) => {
const body = typeof init?.body === 'string' ? init.body : '';
if (body.includes('"server/discover"')) {
// a proxy/gateway that swallows the unrecognized method into an empty 200
return new Response('', { status: 200, headers: { 'content-type': 'application/json' } });
}
return fetch(input, init); // pass everything else through to a 2025-era server
};
const client = new Client({ name: 'repro', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3001/mcp'), { fetch: fetchStub });
await client.connect(transport);
// Expected: probe is unusable → fall back to legacy initialize (as the 400 path does)
// Actual: connect() rejects with SdkError ERA_NEGOTIATION_FAILED
// 'Version negotiation probe failed: Unexpected end of JSON input'
SDK version
Observed in 2.0.0-beta.5; still present in 2.0.0 (read from the published package sources).
Area
Client / Transports
Happy to provide more detail.
Generated by Claude Code
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in src/client/streamableHttp.ts, src/client/versionNegotiation.ts, and src/client/probeClassifier.ts, tracing how unusable 2xx probe responses become normalized replies and are classified. Preserve the distinction between an answered-but-unusable HTTP probe and a genuine network failure, then verify that empty, whitespace, 204, and wrong-content-type 2xx responses use legacy initialize fallback while real network errors and auth/5xx cases retain their current behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- api
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100