Summary
Same class as modelcontextprotocol/python-sdk#3358, applying to the TypeScript reference SDK: the client follows any 3xx redirect the server returns, with no validation of where it lands. A malicious or compromised MCP server (or anything able to influence its 3xx responses) can redirect the client into internal/loopback services (127.0.0.1, Docker, containers) and cloud metadata endpoints. When the redirect target speaks JSON-RPC or SSE — realistically, another local MCP server — its reply is accepted by the client as the MCP server's own (protocol confusion / data leak into any consumer of the session, e.g. an LLM).
I reported the identical gap in the Python SDK yesterday (referenced above). The maintainers there had already attempted a fix once (issue #2106 → PR #2180) that was closed without ever being merged, so the bug is still live. I did not find any open issue covering this surface in the TypeScript SDK either.
Root cause
- The streamable HTTP client transport issues requests through
createFetchWithInit() (packages/core-internal/src/shared/transport.ts).
createFetchWithInit is a pure passthrough: it merges user requestInit and calls the host fetch (defaults to global fetch, i.e. undici on Node).
- Neither the transport nor the wrapper sets
redirect: "manual" nor validates the final host/IP, so undici's default redirect: "follow" is in effect for every GET stream and every authenticated/unauthenticated POST. A 302/303 (or 301/307/308) reply at any point (initial GET, initialize, OAuth-resolved URL, session resume) sends the request onward anywhere, including loopback/link-local.
There is no equivalent of the Python SDK's transport_security.py host checks on the client side (the isLoopbackHost guard that exists covers the OAuth token endpoint only — not the transport's own requests).
Reproducer (self-contained)
Run: npm install @modelcontextprotocol/sdk then node ts_redirect.mjs (Node >= 20).
victim = a 127.0.0.1 HTTP service pretending to be an internal JSON-RPC endpoint (serverInfo.name = "internal-secret-service-ts").
mcp = a mock MCP server whose every POST answers 307 → http://127.0.0.1:<victim>/.
- Client:
new Client(...) + StreamableHTTPClientTransport(mcpUrl) → connect() → getServerVersion().
Standalone file: https://gist.github.com/trickyfalcon/f758f72e77b9a9f365df8d7c0655d2c6
Observed output:
mcp url: http://127.0.0.1:53935/mcp
[mcp] POST /mcp -> 307 to loopback victim
[victim] POST / body={"method":"initialize",...} <- redirected POST lands on the internal service
[mcp] POST /mcp -> 307 to loopback victim
[victim] POST / body={"method":"notifications/initialized",...}
RESULT getServerVersion(): {"name":"internal-secret-service-ts","version":"9.9"}
=> PROOF: client believes the MCP server is internal-secret-service-ts
(it is actually the loopback victim answering through the 307)
Every transport request is redirected into the internal service, and the SDK adopts that service's serverInfo as the server it is talking to.
Impact
- SSRF: a server-controlled redirect makes the client (running inside an agent/desktop harness, which usually has broader network trust than the server) touch internal-only endpoints: Docker API, other local services, AWS/GCP metadata (
169.254.169.254 / metadata.google.internal).
- Protocol confusion / data leak: if the redirect target responds with JSON-RPC or SSE, that content is swallowed directly into the MCP session — internal responses flow to whatever consumes the client (e.g. an LLM) presented as the server's own message.
- Severity is LOW–MEDIUM by default; escalate in hosted agent deployments where the client can reach metadata/loopback the server cannot.
Suggested fix
Mirror the (already-existing) server-side hardening with a client-side counterpart, e.g. in createFetchWithInit/the transport:
- Validate the redirect target before following: reject
127.0.0.0/8, link-local, and private ranges unless explicitly allowed, similar to isLoopbackHost, or
- Make redirect behavior configurable (
redirect: "manual" + explicit Location resolution with an allow-list hook), and/or
- Reuse the token-endpoint host checks for transport requests.
Disclosure
Reported by Mo (@trickyfalcon) on 2026-08-22. Happy to be credited as: Mo (@trickyfalcon, https://trickyfalcon.com)
Related: Python SDK modelcontextprotocol/python-sdk#3358 (identical class, that SDK).
Summary
Same class as modelcontextprotocol/python-sdk#3358, applying to the TypeScript reference SDK: the client follows any 3xx redirect the server returns, with no validation of where it lands. A malicious or compromised MCP server (or anything able to influence its 3xx responses) can redirect the client into internal/loopback services (
127.0.0.1, Docker, containers) and cloud metadata endpoints. When the redirect target speaks JSON-RPC or SSE — realistically, another local MCP server — its reply is accepted by the client as the MCP server's own (protocol confusion / data leak into any consumer of the session, e.g. an LLM).I reported the identical gap in the Python SDK yesterday (referenced above). The maintainers there had already attempted a fix once (issue #2106 → PR #2180) that was closed without ever being merged, so the bug is still live. I did not find any open issue covering this surface in the TypeScript SDK either.
Root cause
createFetchWithInit()(packages/core-internal/src/shared/transport.ts).createFetchWithInitis a pure passthrough: it merges userrequestInitand calls the hostfetch(defaults to globalfetch, i.e. undici on Node).redirect: "manual"nor validates the final host/IP, so undici's defaultredirect: "follow"is in effect for every GET stream and every authenticated/unauthenticated POST. A302/303(or301/307/308) reply at any point (initial GET,initialize, OAuth-resolved URL, session resume) sends the request onward anywhere, including loopback/link-local.There is no equivalent of the Python SDK's
transport_security.pyhost checks on the client side (theisLoopbackHostguard that exists covers the OAuth token endpoint only — not the transport's own requests).Reproducer (self-contained)
Run:
npm install @modelcontextprotocol/sdkthennode ts_redirect.mjs(Node >= 20).victim= a 127.0.0.1 HTTP service pretending to be an internal JSON-RPC endpoint (serverInfo.name = "internal-secret-service-ts").mcp= a mock MCP server whose every POST answers307 → http://127.0.0.1:<victim>/.new Client(...)+StreamableHTTPClientTransport(mcpUrl)→connect()→getServerVersion().Standalone file: https://gist.github.com/trickyfalcon/f758f72e77b9a9f365df8d7c0655d2c6
Observed output:
Every transport request is redirected into the internal service, and the SDK adopts that service's
serverInfoas the server it is talking to.Impact
169.254.169.254/metadata.google.internal).Suggested fix
Mirror the (already-existing) server-side hardening with a client-side counterpart, e.g. in
createFetchWithInit/the transport:127.0.0.0/8, link-local, and private ranges unless explicitly allowed, similar toisLoopbackHost, orredirect: "manual"+ explicitLocationresolution with an allow-list hook), and/orDisclosure
Reported by Mo (@trickyfalcon) on 2026-08-22. Happy to be credited as: Mo (@trickyfalcon, https://trickyfalcon.com)
Related: Python SDK modelcontextprotocol/python-sdk#3358 (identical class, that SDK).