# Your remote MCP server is "connected" and still returns 401

> Source: <https://dev.to/turingcorp/your-remote-mcp-server-is-connected-and-still-returns-401-333g>
> Published: 2026-10-10 15:12:05+00:00

There is a specific kind of bug report that shows up in every remote MCP rollout, and it always sounds the same:

I added the server. It shows as connected. It lists tools. Then the first real call fails with 401.

Nothing is broken. The server is fine, the client is fine, and the credential is fine. What is wrong is the mental model — the one most of us carried over from local `stdio` servers, where "connected" meant "ready."

For a remote MCP server, **reachability and authorization are two different things**, and the first one does not imply the second. Discovery is deliberately open. Execution is not.

A hosted MCP endpoint usually exposes the discovery half of the protocol without any credential at all, because that is what directory crawlers, liveness probes and humans-with-a-browser need:

| What the client does | Credential needed | Typical result without one | 
|---|---|---|
| `initialize` | no | `200` | 
| `tools/list` | no | `200` + the tool list | 
| `tools/call` | **yes** | **`401`** with` WWW-Authenticate` | 

That asymmetry is what makes the failure feel like a client bug. The client's "connected" indicator is driven by `initialize` and `tools/list`, both of which succeed with no credential at all. The lights are green, and the thing still cannot do any work.

So the first diagnostic question is never "is it connected?" It is: **"which half of the protocol is failing?"** If listing works and calling 401s, you are missing a credential, not a connection.

The symmetric mistake is to conclude the token is expired and go re-issue a pass. Before you do that, check two things, because both produce an identical-looking 401:

`Bearer <pass>`, with the word and the space. A client config that accepts `headers` as an opaque map will happily send a bare pass, and the server will correctly reject it. The exact spelling is part of the contract, and it belongs in the config, not in the model's prompt.`401` returned to `npx` command, seeing a 401, and reading it as "auth is required, therefore my auth is working." It is a precise way to learn nothing.
Here is the trap, and it is worth stating precisely because I walked into it while writing this: **`tools/list` is not a credential test at all.** I ran a `tools/list` with a deliberately garbage pass, expecting `401`, and got `200` with the full tool list — because the method is open, so the header is simply not consulted. If your "auth check" is a listing call, it will pass no matter what you put in the header.

The credential only gets exercised where it is actually needed: on `tools/call`. So the three states have to be probed on the calling path, not the listing path.

Set the pass in the environment, never on the command line (it lands in shell history and in transcripts):

``` php
# 1) discovery with no credential at all -> must be 200. This is connectivity, nothing more.
curl -s -o /dev/null -w 'open    %{http_code}\n' -X POST "$MCP_URL" \
  -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

# 2) a deliberately wrong credential ON A CALL -> must be 401.
#    Do not use tools/list here: it answers 200 whatever the header says.
curl -s -o /dev/null -w 'wrong   %{http_code}\n' -X POST "$MCP_URL" \
  -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \
  -H 'authorization: Bearer definitely-not-a-real-pass' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"<your-tool>","arguments":{}}}'

# 3) the real credential on the same call -> the only line that proves anything works end to end.
curl -s -o /dev/null -w 'real    %{http_code}\n' -X POST "$MCP_URL" \
  -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \
  -H "authorization: Bearer $AGENT_PASS" \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"<your-tool>","arguments":{}}}'
```

Three lines, three different questions, and only the third one is a pass/fail about your setup. If line 1 is not `200`, you have a transport problem. If line 2 returns `200`, the call is not actually protected on the server (or you are hitting a different path than you think) — that is a finding about the server, not about your config. If line 2 is `401` and line 3 is also `401`, the pass is genuinely wrong or expired, and that is the only case where re-rolling is the right move.

Bake the same three states into your own test suite rather than into a wiki page. The property you are asserting is not "the server works" — it is **"a credential is required on the calling path, and our client config supplies a working one."**

Remote MCP over HTTP has more than one dialect in the wild, and clients spell them differently. Whoever writes the config has to match the client, not the spec summary:

| Client-side difference | What to write | Failure when you get it wrong | 
|---|---|---|
| Key that holds the server map | `mcpServers` in most JSON-config clients,`servers` in VS Code's`mcp.json` | Server silently ignored — "connected" never happens, or the wrong entry is loaded | 
| Transport value | `http` in most clients; one popular one wants**`streamableHttp`** in camelCase | Client falls back to SSE, which the server does not speak ⇒ `405` , and the panel reports a connection problem | 
| Header-in- `args` bridges | `mcp-remote` takes headers**only** from`--header` ; it does not read an`AUTHORIZATION` environment variable | Config looks right, `tools/list` works, first call 401 — the exact symptom from the top of this post | 
| Timeouts | client-side, in seconds for one client and milliseconds for another | `decide` -style calls that legitimately take minutes get cut at 60 s and read as failures | 

Notice how many of these produce the *same* user-visible symptom. That is the trap: one error message, four unrelated causes, and a strong pull toward "the server is flaky."

The way out is to make the config matrix explicit for your own server and check it into your repo next to the code. Every row above is a thing a reader can copy; every one of them is something we got wrong once before it went into the matrix.

One more design point worth stating plainly, because it affects the calling pattern as much as the config.

Put the pass in the **host's** config. Never accept it as a tool argument. A credential that is an argument is a credential that ends up in prompts, in transcripts and in whatever log pipeline those feed. This is also why retrieval-style tools are best designed as tool calls rather than REST endpoints: the host attaches the header, the agent never holds the value, and a cut-off call can be fetched back without anyone copy-pasting a token.

If you take one thing from this post, take the three-line check. "Connected" is a claim about `tools/list`. Whether your agent can actually call anything is a claim about `tools/call` — and the only honest way to know is to test the three states separately, on purpose, once.

The same three states, plus a worked example of asserting on a tool's contract instead of its answer, are in our public cookbook: [https://mcp.turingcorp.net/](https://mcp.turingcorp.net/) links to the repo, and the relevant sections are `docs/COOKBOOK.md` §7–§10. The seven-field config matrix lives in `llms-install.md` in the same repository, and it is the document that came out of every mistake described above.

Copy this into your own runbook and fill in the right-hand column for your server:

| Symptom | Most likely cause | First thing to check | 
|---|---|---|
| Panel says connected, first call 401 | credential not attached by the client config | the `headers` block, including the`Bearer` prefix | 
| `405` right after adding the server | transport dialect mismatch (SSE fallback) | the `type` field for that specific client | 
| Server entry missing from the client UI | wrong config key ( `servers` vs`mcpServers` ) | the JSON key, not the URL | 
| Call dies at ~60 s, no error body | client tool-call timeout, not a server fault | timeout unit and value; raise it | 
| Works with `curl` , fails in the host | header bridge ( `mcp-remote` and friends) ignores env vars | move the header into `--header` | 
| 401 on *retrieval* of an earlier call | retrieval attempted over REST without a host-attached credential | use the retrieval tool call instead |
