Skip to content

Web client: protocol era "auto" does not fall back to legacy when a 2025-only server rejects the probe with HTTP 400 #2660

Closed

Description

@ivan-kovatchev

Which version line?

v2 (@modelcontextprotocol/inspector@latest)

Which client?

Web

Inspector version

2.10.1

Node version

v24.21.0

Operating system (and browser, for the web client)

Linux x86_64, Chrome 154

Transport

Streamable HTTP

MCP server under inspection

A stateless Streamable HTTP server built on @modelcontextprotocol/sdk 1.32.1 (so it only speaks the 2025-era protocol). It is the stateless pattern from the v1 simpleStatelessStreamableHttp example: sessionIdGenerator: undefined, enableJsonResponse: true, a fresh server per POST.

Protocol era: auto
OAuth: no

package.json:

{
  "name": "inspector-auto-v1-repro",
  "private": true,
  "type": "module",
  "dependencies": {
    "@modelcontextprotocol/sdk": "1.32.1",
    "@modelcontextprotocol/inspector": "2.10.1"
  }
}

server.mjs:

import { createServer } from "node:http";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";

createServer(async (req, res) => {
  if (req.method !== "POST") return res.writeHead(405).end();
  const chunks = [];
  for await (const chunk of req) chunks.push(chunk);
  const body = JSON.parse(Buffer.concat(chunks).toString());
  res.on("finish", () => console.log(`${body.method} -> HTTP ${res.statusCode}`));

  const server = new McpServer({ name: "v1-server", version: "1.0.0" });
  server.registerTool("ping", { description: "ping" }, async () => ({ content: [{ type: "text", text: "pong" }] }));
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
  await server.connect(transport);
  await transport.handleRequest(req, res, body);
}).listen(3333, () => console.log("v1 server on http://localhost:3333/mcp"));

Steps to reproduce

  1. npm install, then node server.mjs
  2. npx mcp-inspector --web --server-url http://localhost:3333/mcp --transport http --protocol-era auto
  3. Toggle the server's connect switch in the web UI

For comparison, the CLI against the same server and era connects fine:

npx mcp-inspector --cli http://localhost:3333/mcp --transport http --protocol-era auto --method tools/list

Expected behavior

The web client falls back to the 2025 initialize handshake and connects, as the CLI does. The v1 server rejects the server/discover probe with an HTTP 400, and the SDK's probe policy treats a 4xx other than 401/403 as a legacy signal.

Actual behavior

The connection goes to "Failed" after the probe. No initialize is sent. The toast reads:

Failed to connect to "localhost:3333"
Version negotiation probe failed: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32000,"message":"Bad Request: Unsupported protocol version: 2026-07-28 (supported versions: 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05, 2024-10-07)"},"id":null}

Server log for the web attempt followed by the CLI run:

server/discover -> HTTP 400        <- web: nothing after this
server/discover -> HTTP 400        <- CLI
initialize -> HTTP 200
notifications/initialized -> HTTP 202
tools/list -> HTTP 200

So "auto" in the web client cannot connect to any 2025-only Streamable HTTP server built on SDK v1 stateless mode, which is the case auto is meant to cover.

Logs, errors, or screenshots

The Network panel shows the single probe: POST with mcp-protocol-version: 2026-07-28 and mcp-method: server/discover, answered with 400 BAD REQUEST, content-type: application/json and the JSON-RPC error body above ("id": null).

A possible cause, from reading the bundled client rather than from debugging it: the "Version negotiation probe failed:" prefix comes from the branch that handles a probe that threw a transport error, which in a browser only falls back for a TypeError. The branch that classifies an HTTP response by status (401/403 abort, 5xx abort, anything else legacy) is not reached. The CLI hits the same 400 with the same SDK and falls back, so the HTTP status may be lost on the web client's path between the backend and the browser before the SDK classifies the probe.

Before you submit

  • I searched existing issues and this is not a duplicate.
  • This is not a security vulnerability report (those go through the private advisory process).

Activity

  1. jarrettdustinqq commented on Oct 9, 2026

    @jarrettdustinqq

    Source-level confirmation on current v2 (I have not rerun the reproduction locally):

    • core/mcp/remote/node/server.ts catches every non-AuthChallengeError from the upstream transport.send() / response wait and serializes only { ok: false, kind: "transport_error", error: msg }.
    • core/mcp/remote/remoteClientTransport.ts receives that envelope and rethrows new Error(json.error).

    That relay therefore drops the original error class plus any structured status / cause chain before the browser-side SDK classifies the server/discover failure. The direct CLI path bypasses this relay, which fits the reported CLI/web difference.

    A narrow regression test should go through the browser remote transport (not only createTransportNode), make the upstream discovery probe return HTTP 400, and assert that initialize is sent afterward. The repair should preserve a typed, bounded wire representation of the classification data and reconstruct it at the browser boundary; parsing the status back out of the message would be fragile because the upstream body is arbitrary server text.

    Relevant source at the current head:

    • relay catch:
      // The response will never arrive now; release the wait so this handler
      // unwinds instead of sitting on a promise nothing can settle.
      if (requestId !== undefined) {
      session.cancelRequestWait(requestId);
      }
      };
      if (clientSignal.aborted) {
      onClientDisconnect();
      } else {
      clientSignal.addEventListener("abort", onClientDisconnect, {
      once: true,
      });
      }
      try {
    • browser reconstruction:
      try {
      json = await this.requestSend(message, options);
      } catch (error) {
      if (requestId !== undefined) {
      this.cancelSseResponseWait(requestId);
      }
      throw error;
      }
      if (json.ok) {
      if (sseWait) {
      await sseWait;
      }
      return;
      }
      if (requestId !== undefined) {
      this.cancelSseResponseWait(requestId);
      }
      if (json.kind === "auth_challenge") {
      // Send-time recovery requires pushAuthState on the existing remote session.
      // Connect-time recovery (startWithRecovery) retries with fresh authState in
      // the connect body instead — see startWithRecovery().
      if (retryCount >= 1 || !this.options.authRecovery?.pushAuthState) {
      throw new AuthChallengeError(
      json.authChallenge,
      json.authChallenge.raw?.httpStatus ?? 401,
      );
      }
      await this.recoverFromAuthChallenge(json.authChallenge, async () => {
      await this.options.authRecovery!.pushAuthState!();
      return this.postSend(message, options, retryCount + 1);
      });
      return;
      }
      throw new Error(json.error);
      }
      private async requestSend(
    • current wire type has no status/cause field:
      }
      export interface RemoteConnectResponseTransportError {
      ok: false;
      kind: "transport_error";
      error: string;
      }
      export type RemoteConnectResponse =
      | RemoteConnectResponseSuccess
      | RemoteConnectResponseAuthChallenge
      | RemoteConnectResponseTransportError
      | { sessionId: string };

    AI-assisted source review; no local runtime verification in this environment.

  2. jarrettdustinqq commented on Oct 9, 2026

    @jarrettdustinqq

    Correction to the first and third source ranges above (the analysis is unchanged):

    • the relay catch/string-only serialization is at
      } catch (err) {
      if (requestId !== undefined) {
      session.cancelRequestWait(requestId);
      }
      if (err instanceof AuthChallengeError) {
      session.noteAuthChallengeDeliveredViaHttp();
      return c.json({
      ok: false,
      kind: "auth_challenge",
      authChallenge: err.authChallenge,
      });
      }
      const msg = err instanceof Error ? err.message : String(err);
      return c.json({ ok: false, kind: "transport_error", error: msg });
    • the RemoteSendResponse transport-error variant is at
      export type RemoteSendResponse =
      | { ok: true }
      | {
      ok: false;
      kind: "auth_challenge";
      authChallenge: AuthChallenge;
      }
      | {
      ok: false;
      kind: "transport_error";
      error: string;
      };
  3. added
    v2Issues and PRs for v2
    bugSomething isn't working
    on Oct 12, 2026
  4. cliffhall commented on Oct 12, 2026

    @cliffhall
    Member

    Triage: Priority High (total 9)

    • Severity 4 — the web client cannot connect at all, under auto, to a 2025-only stateless Streamable HTTP server (the case auto exists for); the CLI handles the same server
    • Urgency 4 — reproduces on the published 2.10.1; workaround is forcing the legacy era by hand
    • Bonuses: +1 bug label

    Board: #28, Status Incoming (awaiting maintainer review — no milestone yet).

  5. added this to the v2.11.0 milestone on Oct 12, 2026
  6. self-assigned this
    on Oct 12, 2026
  7. cliffhall commented on Oct 12, 2026

    @cliffhall
    Member

    Fixed by #2684, merged into v2/main.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingv2Issues and PRs for v2

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions