Repository navigation
McpServer.connect() Appears to Return null/undefined with Per-Request Server Instantiation and await; Leads to "Method not found" #3058
Description
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/sdkVersion:1.11.4(also observed with1.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:
- The
transport.setRequestListener(...)call, which normally happens insideMcpServer.connect(), is likely not being effectively executed for the transport instance. - When
transport.handleRequest(...)is later called,transport.requestListenerisundefined. - This results in either:
- A "Method not found" JSON-RPC error if
sessionIdGeneratorforStreamableHTTPServerTransportisundefined. - A "Bad Request: Server not initialized" JSON-RPC error (HTTP 400) if
sessionIdGeneratoris explicitly set (e.g., torandomUUID), astransport.handleRequesthas a specific check forthis.requestListener.
- A "Method not found" JSON-RPC error if
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.");
}- 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`);
});- 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"]
}
- 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
hoangchunghien commentedon Oct 26, 2026
I ran your setup against
@modelcontextprotocol/sdk@1.11.4with Express 4.19.2, and I don't think anything is wrong withconnect(). Four separate things are going on.1.
connect()returningundefinedis expected. It is declaredconnect(transport: Transport): Promise<void>inserver/mcp.d.ts. It wires the transport'sonmessageto the server and resolves with nothing, so there is noServerConnectionto get back. Theawaitis correct, because it really is async. In my run it loggedconnect() returned: undefinedon every request, including the ones that worked.2. "Method not found" comes from the method names.
AppTools.country-listandmcp.describearen't MCP methods. The server name is not a namespace for tools. A tool is listed and called through the standard methods:With your per-request factory and
sessionIdGenerator: undefined, both of these return results. Your two original calls return-32601.3. "Server not initialized" with
randomUUIDis the session model. With a session id generator, the transport is stateful. The first request must beinitialize, and later requests must reach the same transport instance with themcp-session-idheader. A new transport per request never sees its owninitialize, so every request gets the 400. Keep a map of session id to transport, filled in fromonsessioninitialized, 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
resourceneeds auri:Without it, the SDK client rejects the
tools/callresult with a zodinvalid_unionerror. With theuriadded, it parses. If you only need the data,{ type: "text", text: JSON.stringify(countries) }is simpler.For testing by hand,
npx @modelcontextprotocol/inspectorsends the rightinitialize,tools/listandtools/callsequence for you.I checked this with Claude Code and reran every step above locally before posting.
Ben-Home commentedon Oct 27, 2026
Two things are being read as one bug here, and separating them should save you the debugging session.
First,
connect()returningundefinedis the documented behavior in the version you are on, not a failure. In 1.11.4 the signature isasync 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. Soconst connection = await server.connect(transport)will always beundefinedon 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 whetherconnect()returned something, it is whether that specific transport instance hadonmessageset beforehandleRequest. The two error shapes you are seeing are the fingerprint of a transport whose listener was never attached:sessionIdGenerator: undefinedand 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 ownmcp.describelooks unknown.sessionIdGeneratorset and no listener gives the HTTP 400 "Server not initialized", becausehandleRequestbails 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 firsthandleRequest. Registering tools afterconnect()is fine, but connecting after handling is not.The shape that holds under load, stateless:
If you do want sessions, the extra step is not optional: a non-undefined
sessionIdGeneratormeans one server plus transport pair permcp-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
handleRequestis 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.