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.
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 (
mcp2.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:
npx @modelcontextprotocol/inspector --cli python server.py --method tools/listFor an HTTP server, give it the URL instead:
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:
@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:
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:
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:
npx @modelcontextprotocol/inspector --cli uvx mcp-server-fetch --method tools/list{"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:
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:
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:
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:
claude mcp add my-server -e NOTES_API_KEY=xxx -- uv run server.pyFor 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:
mcp.run(transport="stido") # typoValueError: Unknown transport: stidoThe 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:
curl -i -X POST http://127.0.0.1:8010/ -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'HTTP/1.1 404 Not Found
Not FoundPointing 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:
{"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:
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"}}}'Invalid Origin header
HTTP status: 403Send the wrong Host instead:
curl -X POST http://127.0.0.1:8010/mcp -H "Host: evil.example.com" ...Invalid Host header
HTTP status: 421Both 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:
curl -i -X POST http://127.0.0.1:8010/mcp -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" -d '...'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
- How to test an MCP server with the MCP Inspector, deeper into Inspector itself.
- MCP stdio vs Streamable HTTP, the transport differences behind steps 2 and 6.
- How to add MCP servers to Claude Code and how to set up MCP servers in Cursor, for the client-side config these errors trace back to.
- How MCP authorization works (OAuth 2.1), the 401 case in full.
- How to pin MCP server versions, if an upgrade broke a server that used to connect.