Build MCP servers

How to debug an MCP server that won't connect

A server that will not connect gives you one of a short list of errors. Reproduce the error outside your AI client first, and the fix is usually obvious.

By
Dan Flanagan
Updated
October 15, 2026
Tested with
Python 3.13.12, mcp 2.3.0, MCP Inspector 2.11.0 (Node 23.7.0), curl 8.7.1, on macOS, October 2026
Reading time
7 min

New to MCP? What is an MCP server? covers the basics first.

An MCP server that will not connect fails in a handful of ways: a tool prints somewhere it shouldn't, the client can't find the command, an environment variable never arrives, the server throws on startup, the URL or transport is wrong, the server's own Host or Origin check rejects the request, or a protected server returns 401. Each has a distinct symptom and fix, and you can reproduce all of them without opening Claude Code or Cursor.

Prerequisites

  • A server you can run locally. The examples below use a small Python server built with the official SDK (mcp 2.3.0), the same one from How to build an MCP server in Python.
  • Node.js 20 or newer, to run MCP Inspector through npx. This article used Inspector 2.11.0.
  • curl, for the HTTP cases.

Step 1: Isolate the server with MCP Inspector

Before touching your AI client's config, connect to the server directly. MCP Inspector is a client built for this: point it at a command or a URL, and it shows you the handshake, the tool list and the raw errors with nothing else in the way.

For a stdio server, give Inspector the command:

bash
npx @modelcontextprotocol/inspector --cli python server.py --method tools/list

For an HTTP server, give it the URL instead:

bash
npx @modelcontextprotocol/inspector --cli http://127.0.0.1:8000/mcp --method tools/list

--cli runs Inspector as a one-shot command and prints JSON, used throughout this article so the output is exact. Drop --cli for a browser UI with the request and response for every call.

What you should see for a working server is a tools array with every tool's name, description and schema. If that is what you get, the problem is the client's config: a wrong path, a scope the server isn't loaded into, or a session that started before you added it. If Inspector also fails, you've ruled out the client. Everything below reproduces one way it can fail.

Step 2: A tool prints to stdout and corrupts the stream

A stdio server talks to its client over standard input and standard output. Every byte on stdout has to be a JSON-RPC message, so a stray print() anywhere in your code breaks the stream. Here is a tool that does that:

python
@mcp.tool()
def list_notes() -> list[str]:
    """List the titles of every saved note."""
    print(f"listing notes in {NOTES_DIR}")
    return sorted(p.stem for p in NOTES_DIR.glob("*.md"))

Calling it through the Python SDK's own client gives this:

text
Failed to parse JSONRPC message from server
Traceback (most recent call last):
  ...
pydantic_core._pydantic_core.ValidationError: 1 validation error for union[JSONRPCRequest,JSONRPCNotification,JSONRPCResponse,JSONRPCError]
  Invalid JSON: expected value at line 1 column 1 [type=json_invalid, input_value='listing notes in /Users/...notes', input_type=str]

This client logs the bad line and keeps reading, so the next line, the real response, still arrives. MCP Inspector's CLI drops the same bad line with no message at all: the tools/call result comes back clean, and you would never notice unless you were watching stderr. Neither behavior is something to rely on. A client that merges two JSON values on one line, or reads a response one line later than it expected, can hang waiting for a reply that already went by.

The fix: never call print(). Logging libraries that default to stdout, a C extension that prints a banner on import, and click.echo() land on the same stream. Log to stderr instead:

python
import sys
print(f"listing notes in {NOTES_DIR}", file=sys.stderr)

The MCP debugging guide states the same rule: for stdio, everything written to stderr is captured by the host automatically, so stderr is where diagnostic output belongs. Streamable HTTP has no equivalent capture: stderr there just goes to wherever you started the process, so use real log aggregation or OpenTelemetry if you serve over HTTP.

Step 3: The client can't find the command or runtime

A client that launches a server needs the exact command in its PATH. Point Inspector at a runtime that isn't installed:

bash
npx @modelcontextprotocol/inspector --cli uvx mcp-server-fetch --method tools/list
text
{"error":{"code":"error","message":"spawn uvx ENOENT"}}

ENOENT is the operating system saying it looked for uvx and found nothing. The same happens with a typo'd command, a binary missing from the client's PATH, or python where the system only has python3.

How you'd see it in a real client (from their docs): Claude Code's claude mcp list prints ✘ Failed to connect for a server whose command can't start. Cursor's Output panel, under the MCP Logs dropdown, shows the same spawn error. Claude Desktop writes it to a log file instead: ~/Library/Logs/Claude on macOS, %APPDATA%\Claude\logs on Windows.

The fix: use the full path to the binary (which uvx finds it), or install the runtime where the client runs. A client launched from a GUI icon often has a different PATH than your terminal, so a command that works when you type it can still fail inside Claude Desktop or Cursor.

Step 4: Environment variables never reach the server

A server that reads a required variable and finds nothing crashes before it can answer anything:

python
API_KEY = os.environ["NOTES_API_KEY"]

Starting it through the SDK client with no NOTES_API_KEY set gives, on the server's own stderr:

text
Traceback (most recent call last):
  File "server_env.py", line 5, in <module>
    API_KEY = os.environ["NOTES_API_KEY"]
KeyError: 'NOTES_API_KEY'

and on the client side:

text
mcp.shared.exceptions.MCPError: Connection closed

"Connection closed" is what every client sees when the server exits before finishing the handshake. It never says why, so find the server's own error instead. The MCP debugging guide names the cause directly: a stdio server inherits only a limited, platform-dependent subset of environment variables, not your shell's full environment. A server that works when you run it yourself can fail the moment a client launches it, because your shell's exports never reached the client's process.

The fix: pass the variable explicitly in the server's config instead of relying on inheritance. For Claude Code, -e on claude mcp add:

bash
claude mcp add my-server -e NOTES_API_KEY=xxx -- uv run server.py

For Cursor or Claude Desktop's JSON config, an env object on the server entry does the same thing. Check it loaded by printing os.environ.get("NOTES_API_KEY") to stderr at startup, before anything else runs.

Step 5: The server crashes before it finishes starting

Not every startup crash is a missing variable. Any uncaught exception at import time or in __main__ kills the process the same way:

python
mcp.run(transport="stido")  # typo
text
ValueError: Unknown transport: stido

The client again just reports Connection closed. Run the server by itself first, outside any client: python server.py. The traceback prints to your terminal, faster than digging through a client's logs for a process you can't see.

The fix depends on the exception, but the method doesn't change: run the server standalone, read the traceback, fix that line, and only then put it back behind a client or Inspector.

Step 6: Wrong URL or transport for an HTTP server

A Streamable HTTP server listens on one path, usually /mcp. Posting to the wrong one is a plain HTTP 404:

bash
curl -i -X POST http://127.0.0.1:8010/ -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
text
HTTP/1.1 404 Not Found
Not Found

Pointing Inspector at the same bare URL fails earlier, before it sends a request, because Inspector can't tell what transport a bare origin with no path is using:

text
{"error":{"code":"error","message":"Transport type not specified and could not be determined from URL: http://127.0.0.1:8010/."}}

The fix: use the exact path and transport the server's docs give you, usually /mcp for Streamable HTTP. A client config's separate transport or type field has to match what the server speaks: http for Streamable HTTP, sse for the older transport most servers have dropped. MCP stdio vs Streamable HTTP covers which a given server needs.

Step 7: The server rejects the Host or Origin header

A Streamable HTTP server built with the official SDK checks the Host and Origin headers on every request, as a defense against DNS rebinding: a page in your browser trying to reach a server you only meant to expose to yourself on localhost. Send a request with the wrong Origin:

bash
curl -X POST http://127.0.0.1:8010/mcp \
  -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
  -H "Origin: http://evil.example.com" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
text
Invalid Origin header
HTTP status: 403

Send the wrong Host instead:

bash
curl -X POST http://127.0.0.1:8010/mcp -H "Host: evil.example.com" ...
text
Invalid Host header
HTTP status: 421

Both checks live in the server, not the client, so no client-side config fixes a header the server itself is rejecting. You hit this behind a reverse proxy or tunnel that rewrites the Host header, or a client library that sets an Origin the server doesn't expect.

The fix: if you control the server, add the client's real origin to its allowed list (the Python SDK's MCPServer takes this in its transport security settings). Otherwise connect the way the server's own docs say to, usually a plain http://localhost:<port>/mcp with no proxy in between.

Step 8: 401 from a server that needs auth

A server protected with a bearer token returns 401 for any request with no token, or the wrong one:

bash
curl -i -X POST http://127.0.0.1:8010/mcp -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" -d '...'
text
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="http://127.0.0.1:8010/.well-known/oauth-protected-resource/mcp"

{"error": "invalid_token", "error_description": "Authentication required"}

The www-authenticate header carries a resource_metadata URL, MCP's OAuth discovery hook: a client that understands it fetches that URL to find the authorization server and starts a login flow on its own.

How you'd see it in a real client (from their docs): Claude Code flags a server that answers 401 or 403 as needing authentication in /mcp, and tries OAuth discovery before giving up. If you set the Authorization header yourself with a static token, Claude Code reports a failed connection instead, since that credential is yours to fix.

The fix: for a server with its own login flow, let the client run it (/mcp, or claude mcp login <name>) rather than hand-rolling a token. For a server that only takes a static key, check the key is current and reaches the server as a header, not a query parameter it never reads. How MCP authorization works (OAuth 2.1) has the full flow.

A 403 from a gateway in front of the server is a different problem. Statio, for example, refuses a call the caller has no grant for with "You don't have permission to call tool 'x'". The connection worked, and the fix is a grant in MCP permissions.

Conclusion

Every one of these failures is reproducible outside your AI client, with Inspector, a bare SDK client, or curl. Isolate first, read the exact error, and fix the layer the error is actually in: your tool code, the command the client launches, the variables it passes, or the server's own transport and auth checks.

Next

Questions

The client only knows the process exited before finishing the handshake. The real error is on the server's own stderr: run it directly, outside the client, to see the traceback.

The client launches the server with its own PATH and environment, which can be narrower than your shell's. A command or variable your shell resolves may not exist for the client's process.

Not on stdout in a stdio server: it corrupts the JSON-RPC stream. Print to stderr instead, which stdio clients capture as logs without it affecting the protocol.

The server's DNS rebinding protection rejected the request's Origin header. It is a server-side check, so the fix is adding your client's real origin to the server's allowed list, or removing a proxy that rewrites the header.