Skip to content

McpServer.connect() Appears to Return null/undefined with Per-Request Server Instantiation and await; Leads to "Method not found" #3058

Open

Description

@M-ldhfodh

Hello MCP SDK Team,

I'm encountering an issue where McpServer.connect(transport) appears to return null or undefined instead of a valid ServerConnection object when using StreamableHTTPServerTransport. This subsequently prevents the transport's request listener from being set, leading to "Method not found" errors for all MCP calls (including mcp.describe).

Environment:

  • @modelcontextprotocol/sdk Version: 1.11.4 (also observed with 1.11.0)
  • Node.js Version: v20.16.0
  • Operating System: macOS Sonoma 14.4 (Apple M1 Pro)
  • Express.js Version: 4.19.2 (Please update if different)
  • TypeScript Version: 5.4.5 (Please update if different)

Problem Description:

In my application, which uses Express.js, I'm setting up an McpServer with StreamableHTTPServerTransport. The primary issue is that the synchronous call to server.connect(transport) seems to complete without throwing an immediate error, but the returned value assigned to our connection variable is observed to be null or undefined through logging.

Because the connection object is not valid:

  1. The transport.setRequestListener(...) call, which normally happens inside McpServer.connect(), is likely not being effectively executed for the transport instance.
  2. When transport.handleRequest(...) is later called, transport.requestListener is undefined.
  3. This results in either:
    • A "Method not found" JSON-RPC error if sessionIdGenerator for StreamableHTTPServerTransport is undefined.
    • A "Bad Request: Server not initialized" JSON-RPC error (HTTP 400) if sessionIdGenerator is explicitly set (e.g., to randomUUID), as transport.handleRequest has a specific check for this.requestListener.

The core problem is that McpServer.connect() is not returning a valid ServerConnection object as expected by its type signature, thus breaking the link with the transport.

My Application Code Structure:

Here's a simplified representation of the relevant parts of my application code where the issue is observed.

1. src/mcp/mcpServer.ts:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { Application, Request, Response } from "express";
import logger from "../logger"; // My application's logger (e.g., Winston)
// import { randomUUID } from "crypto"; // If using randomUUID for sessionIdGenerator

async function getCountryList() {
  return [
    { name: "United States", code: "US" },
    { name: "Canada", code: "CA" },
  ];
}

// Original problematic approach: Factory function called per request
function getServer(): McpServer {
  const server = new McpServer({ name: "AppTools", version: "1.0.0" });
  server.tool("country-list", "Returns a list of all countries in the system", {}, async () => {
    const countries = await getCountryList();
    return {
      content: [
        {
          type: "resource",
          resource: { mimeType: "application/json", text: JSON.stringify(countries) },
        },
      ],
    };
  });
  logger.info("Registered tool 'country-list' on new server instance.");
  return server;
}

export function setupMCPServer(app: Application) {
  app.post("/mcp", async (req: Request, res: Response) => {
    try {
      const server = getServer(); // Problem: New server instance on every request
      const transport = new StreamableHTTPServerTransport({
        sessionIdGenerator: undefined, // Also tested with randomUUID
      });

      logger.info("Connecting transport to server (per-request instance)...");
      console.log("Connecting transport to server (per-request instance)..."); // For visibility

      // Problem: 'await' on a synchronous function.
      // This is where 'connection' effectively becomes null/undefined based on logs.
      const connection = await server.connect(transport); 
      
      if (connection) {
        logger.info("server.connect seemed to return a truthy connection object.");
        console.log("server.connect seemed to return a truthy connection object.");
      } else {
        logger.error("CRITICAL: server.connect returned null or undefined.");
        console.error("CRITICAL: server.connect returned null or undefined.");
      }

      logger.info("Transport supposedly connected to server.");
      console.log("Transport supposedly connected to server.");

      await transport.handleRequest(req, res, req.body);
      logger.info("Request handled successfully by transport.");
      console.log("Request handled successfully by transport.");

    } catch (error: any) {
      logger.error("Error handling MCP request:", { message: error.message, stack: error.stack });
      console.error("Error handling MCP request:", error);
      if (!res.headersSent) {
        res.status(500).json({
          jsonrpc: "2.0",
          error: { code: -32603, message: "Internal server error" },
          id: req.body?.id || null,
        });
      }
    }
  });

  app.get("/mcp", (req: Request, res: Response) => { 
    res.status(405).json({ jsonrpc: "2.0", error: { code: -32000, message: "Method Not Allowed. Use POST." }, id: null });
  });
  app.delete("/mcp", (req: Request, res: Response) => { 
    res.status(405).json({ jsonrpc: "2.0", error: { code: -32000, message: "Method Not Allowed. Use POST." }, id: null });
  });
  logger.info("Original MCP routes configured.");
}
  1. src/app.ts (How setupMCPServer is called):
import express from "express";
import * as mcpServerSetup from "./mcp/mcpServer"; // Path to the mcpServer.ts above
import logger from "./logger"; // My application's logger

const app = express();
const port = process.env.API_PORT;

app.use(express.json());

try {
  mcpServerSetup.setupMCPServer(app);
  logger.info("Main MCP server setup completed.");
} catch (error) {
  logger.error("Failed to set up main MCP server:", error);
  process.exit(1);
}

app.listen(port, () => {
  logger.info(`Main Server is running at http://localhost:${port}`);
  logger.info(`Main MCP server available at http://localhost:${port}/mcp`);
});
  1. tsconfig.json:
{
  "compilerOptions": {
    "target": "es2018",
    "module": "commonjs",
    "rootDir": "./src",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "allowJs": true,
    "outDir": "./dist/",
    "downlevelIteration": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "noImplicitAny": false,
    "strictNullChecks": false,
    "noImplicitReturns": false,
    "skipLibCheck": true,
    "types": ["./src/types/express"] 
  },
  "exclude": ["node_modules"],
  "include": ["src/**/*", "src/**/*.json"]
}
  1. package.json (Key Dependencies - please specify your exact versions):
{
  "name": "my-application",
  "version": "1.0.0",
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.11.4", 
    "express": "^4.19.2", 
    "typescript": "^5.4.5" 
  }
}

Steps to Reproduce (Using the application structure above):

Set up the project with the described files and relevant dependencies.

Compile TypeScript.

Start the server.

Send a POST request:

curl -v -X POST http://localhost:3777/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":"test-country","method":"AppTools.country-list","params":{}}'

Or for mcp.describe:

curl -v -X POST http://localhost:3777/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":"test-describe","method":"mcp.describe","params":{}}'

Observed Behavior:

The curl command receives a "Method not found" error:

{"jsonrpc":"2.0","id":"test-country","error":{"code":-32601,"message":"Method not found"}}

Server logs show the CRITICAL: server.connect returned null or undefined. message (or similar if connection logging is added).

Expected Behavior:
McpServer.connect(transport) should return a valid ServerConnection object. The curl command should successfully invoke the AppTools.country-list method or mcp.describe.

Debugging Notes & Attempts:

We have confirmed that McpServer.connect is a synchronous method, so the await in my original code is incorrect.

Even when correcting my application structure to use a singleton McpServer instance and calling server.connect(transport) synchronously (as demonstrated in separate minimal tests), the core issue of connect() returning null/undefined persisted. This suggests the problem is not solely due to my initial incorrect usage patterns but might indicate a deeper issue within the SDK's connect logic when interacting with StreamableHTTPServerTransport.

Could you please investigate why McpServer.connect() might be failing to establish a proper connection and return null/undefined in this scenario with StreamableHTTPServerTransport?

Thank you for your time and assistance!

Activity

  1. added
    v1Issues / PRs related to v1.x
    on Oct 20, 2026
  2. hoangchunghien commented on Oct 26, 2026

    @hoangchunghien

    I ran your setup against @modelcontextprotocol/sdk@1.11.4 with Express 4.19.2, and I don't think anything is wrong with connect(). Four separate things are going on.

    1. connect() returning undefined is expected. It is declared connect(transport: Transport): Promise<void> in server/mcp.d.ts. It wires the transport's onmessage to the server and resolves with nothing, so there is no ServerConnection to get back. The await is correct, because it really is async. In my run it logged connect() returned: undefined on every request, including the ones that worked.

    2. "Method not found" comes from the method names. AppTools.country-list and mcp.describe aren't MCP methods. The server name is not a namespace for tools. A tool is listed and called through the standard methods:

    curl -s -X POST http://localhost:3777/mcp \
      -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
      -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
    
    curl -s -X POST http://localhost:3777/mcp \
      -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
      -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"country-list","arguments":{}}}'

    With your per-request factory and sessionIdGenerator: undefined, both of these return results. Your two original calls return -32601.

    3. "Server not initialized" with randomUUID is the session model. With a session id generator, the transport is stateful. The first request must be initialize, and later requests must reach the same transport instance with the mcp-session-id header. A new transport per request never sees its own initialize, so every request gets the 400. Keep a map of session id to transport, filled in from onsessioninitialized, and reuse the stored transport when the header is present. Otherwise stay stateless (sessionIdGenerator: undefined), which is what your per-request pattern suits. The README's "Streamable HTTP" section has both variants.

    4. Once the method name is right, your tool result will fail. An embedded resource needs a uri:

    content: [{ type: "resource", resource: { uri: "app://countries", mimeType: "application/json", text: JSON.stringify(countries) } }]

    Without it, the SDK client rejects the tools/call result with a zod invalid_union error. With the uri added, it parses. If you only need the data, { type: "text", text: JSON.stringify(countries) } is simpler.

    For testing by hand, npx @modelcontextprotocol/inspector sends the right initialize, tools/list and tools/call sequence for you.

    I checked this with Claude Code and reran every step above locally before posting.

  3. Ben-Home commented on Oct 27, 2026

    @Ben-Home

    Two things are being read as one bug here, and separating them should save you the debugging session.

    First, connect() returning undefined is the documented behavior in the version you are on, not a failure. In 1.11.4 the signature is async connect(transport: Transport): Promise<void> (src/shared/protocol.ts, line 280 of the tagged source), and the connection object as a return value is a later change. So const connection = await server.connect(transport) will always be undefined on 1.11.x. That logging line is telling you the truth about the SDK, not about your transport.

    Second, the listener is attached synchronously inside connect(), so the useful question is not whether connect() returned something, it is whether that specific transport instance had onmessage set before handleRequest. The two error shapes you are seeing are the fingerprint of a transport whose listener was never attached:

    • sessionIdGenerator: undefined and no listener gives the JSON-RPC "Method not found" path, because the request is dispatched with nothing to hand it to, so every method including your own mcp.describe looks unknown.
    • sessionIdGenerator set and no listener gives the HTTP 400 "Server not initialized", because handleRequest bails on the missing listener before it ever looks at the session.

    The usual cause with per-request instantiation is a transport instance that differs from the one that was connected, or a connect() whose promise was not awaited before the first handleRequest. Registering tools after connect() is fine, but connecting after handling is not.

    The shape that holds under load, stateless:

    app.post("/mcp", async (req, res) => {
      const server = new McpServer({ name: "x", version: "1.0.0" });
      registerTools(server); // before connect, no I/O in this path
      const transport = new StreamableHTTPServerTransport({
        sessionIdGenerator: undefined,
        enableJsonResponse: true,
      });
      res.on("close", () => { transport.close(); server.close(); });
      await server.connect(transport);
      if (!transport.onmessage) throw new Error("transport not connected"); // fail loud at the attach point
      await transport.handleRequest(req, res, req.body);
    });

    If you do want sessions, the extra step is not optional: a non-undefined sessionIdGenerator means one server plus transport pair per mcp-session-id, kept in a map and reused, because a fresh pair per request cannot resume an existing session and will 400. Stateless and session-ful are two different handlers, not one handler with a flag.

    The assertion before handleRequest is worth keeping on purpose. It converts a silent dispatch failure into a stack trace at the exact line where the transport and the server stopped being the same object, which is where the real bug lives.

    We run a remote MCP server with per-request handlers (business data connectors), and separating the stateless handler from the session-ful one is the change that removed this whole class of failure for us.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    v1Issues / PRs related to v1.x

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions