writing/8 Sep 2026/14 min read

Connecting Claude, Cursor and Codex to a remote MCP server

Remote MCP over Streamable HTTP removed the local bridge process, and with it most of the reasons MCP setups used to fail. What is left is a smaller set of confusions: which transport your client actually opened, what the OAuth handshake is exchanging, and why the tool list sometimes comes back shorter than the documentation promised.

Two kinds of MCP server, and why it matters

Nearly every MCP integration you have installed is a local server: a package your client launches as a child process, talking JSON-RPC over stdin and stdout. It holds your API key, translates tool calls into HTTP requests against some vendor's API, and dies when your editor closes.

A remote server skips the middle layer. The vendor's service speaks MCP itself, over HTTPS, and your client is just an HTTP client. Nothing is installed, nothing runs locally, and there is no npm package sitting between your credentials and the API.

The practical differences show up fast. A local server needs a runtime you have to keep current, and a key pasted into a config file in plaintext. A remote server needs a URL, and authenticates through OAuth so the token lives in your client's credential store and can be revoked from the vendor's dashboard. When a local server breaks you debug a subprocess; when a remote one breaks you read an HTTP status code.

Throughout this article the examples use c1k.me, a short link service whose MCP endpoint is the service itself rather than a wrapper. The configuration patterns are the same for any remote MCP server, so substitute your own URL freely.

Streamable HTTP in one paragraph

Streamable HTTP replaced the older HTTP+SSE transport. Your client POSTs a single JSON-RPC message to one endpoint URL and reads the response. The server may answer with plain JSON, or upgrade to an SSE stream if it needs to send several messages for one request. A server is free to implement only the JSON half, and many do, because most tool calls are a simple request and response.

That freedom is the source of the most common setup failure. A client configured for the old SSE transport opens a long-lived GET against the endpoint expecting an event stream. A JSON-only server has nothing to serve on GET and answers 405 Method Not Allowed. The client reports that the server is unreachable, which is misleading: the server is fine, the verb is wrong.

If a client offers both “SSE” and “HTTP” (or “streamable HTTP”) options for a remote server, choose HTTP unless the vendor documents otherwise. Picking SSE against a JSON-only server produces exactly the 405 above.

What the handshake actually exchanges

Before debugging any client, it helps to have seen the raw conversation. It is three messages, and you can drive all of them with curl.

# 1. initialize — agree on a protocol version
curl -s -X POST https://c1k.me/mcp/org_EXAMPLE \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "jsonrpc":"2.0","id":1,"method":"initialize",
    "params":{
      "protocolVersion":"2025-11-25",
      "capabilities":{},
      "clientInfo":{"name":"curl","version":"1.0.0"}
    }
  }'

# 2. notifications/initialized — a notification, so 202 and no body
curl -s -i -X POST https://c1k.me/mcp/org_EXAMPLE \
  -H "Authorization: Bearer $TOKEN" \
  -H "MCP-Protocol-Version: 2025-11-25" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'

# 3. tools/list — what this token is actually allowed to see
curl -s -X POST https://c1k.me/mcp/org_EXAMPLE \
  -H "Authorization: Bearer $TOKEN" \
  -H "MCP-Protocol-Version: 2025-11-25" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'

Two details trip people up. First, after initialization every request must carry MCP-Protocol-Version; omitting it is a protocol error, not a warning, and servers are entitled to answer 400. Second, a notification has no id and therefore no response — a 202 with an empty body is success, not a failure.

If those three calls work with curl and your client still cannot connect, the problem is in the client configuration and not in the server. That single fact saves a lot of time.

The OAuth part, without the spec language

When you connect without a token, a well-behaved remote server answers 401 with a WWW-Authenticate header pointing at its protected-resource metadata. Your client fetches that, discovers the authorization and token endpoints, opens a browser, and you approve a consent screen naming the scopes. The client exchanges the resulting code for an access token using PKCE, so the code is useless to anyone who intercepts it.

Three things about that flow cause real-world failures.

Dynamic client registration may not be available

Many clients expect to register themselves on the fly. A server that deliberately does not advertise dynamic registration — a reasonable choice for a small service that wants a known list of clients — will reject that attempt. The fix is not on your side: an administrator has to preregister the client and its exact redirect URI.

Access tokens are short-lived

Fifteen minutes is typical. Your client refreshes silently, so you never notice — until a client with a buggy refresh implementation starts failing an hour into a session. If a connection works and then dies reproducibly after a fixed interval, suspect refresh rather than the network.

Refresh tokens usually rotate

Each refresh issues a new token and invalidates the old one. Replaying an old one is treated as theft and revokes the whole family. This is why copying a client config between two machines quietly breaks both: they fight over the same rotating token. Authenticate each machine separately.

Client by client

Every configuration below points at the same URL. Where a client supports both OAuth and a static bearer token, prefer OAuth for anything interactive and reserve tokens for CI and headless agents.

Claude Code

The cleanest of the set, because scope is explicit. --scope project writes to a committed .mcp.json, --scope user keeps it to your machine.

claude mcp add --transport http c1k https://c1k.me/mcp/org_EXAMPLE

# then, inside a session, authenticate
/mcp

# headless alternative
claude mcp add --transport http c1k https://c1k.me/mcp/org_EXAMPLE \
  --header "Authorization: Bearer $C1K_MCP_TOKEN"

claude mcp list
claude mcp get c1k

Committing .mcp.json to a shared repository is genuinely useful — everyone gets the server, each person authenticates individually. Never commit the header variant.

Claude Desktop

Add a custom connector in settings and approve consent in the browser, or edit claude_desktop_config.json and restart. On macOS it lives in ~/Library/Application Support/Claude/, on Windows in %APPDATA%\Claude\.

{
  "mcpServers": {
    "c1k": {
      "type": "http",
      "url": "https://c1k.me/mcp/org_EXAMPLE"
    }
  }
}

The restart is not optional; the config is read at launch. If your edit appears to have no effect, that is almost always why.

Codex CLI

Configuration is TOML in ~/.codex/config.toml. Reference an environment variable rather than pasting a literal token.

[mcp_servers.c1k]
url = "https://c1k.me/mcp/org_EXAMPLE"

[mcp_servers.c1k.http_headers]
Authorization = "Bearer ${C1K_MCP_TOKEN}"
"MCP-Protocol-Version" = "2025-11-25"

Cursor

Project config in .cursor/mcp.json, global in ~/.cursor/mcp.json. Omit headers entirely to use the browser OAuth flow.

{
  "mcpServers": {
    "c1k": {
      "url": "https://c1k.me/mcp/org_EXAMPLE",
      "headers": {
        "Authorization": "Bearer ${env:C1K_MCP_TOKEN}",
        "MCP-Protocol-Version": "2025-11-25"
      }
    }
  }
}

Check Settings → MCP after saving. A connected server shows its tool list; a server that connected but lists no tools is a scope problem, covered below.

VS Code with Copilot agent mode

VS Code reads .vscode/mcp.json. Its input mechanism is the best answer to the “never commit a token” problem of any client here: declare the secret as a prompted input and the editor asks at runtime.

{
  "inputs": [
    {
      "id": "c1k-token",
      "type": "promptString",
      "description": "c1k.me MCP service token",
      "password": true
    }
  ],
  "servers": {
    "c1k": {
      "type": "http",
      "url": "https://c1k.me/mcp/org_EXAMPLE",
      "headers": { "Authorization": "Bearer ${input:c1k-token}" }
    }
  }
}

JetBrains IDEs

Settings → Tools → AI Assistant → Model Context Protocol. Add a server, choose the remote HTTP option, paste the URL. The dialog also accepts a pasted JSON block in the same shape as Cursor's. Keep tokens in the IDE password safe rather than in a run configuration that syncs.

ChatGPT

Added as a custom connector in settings, authenticated through OAuth in the browser. This is the client most likely to hit the dynamic-registration wall, because it expects to register itself. Against a server with a preregistered client list, someone has to add ChatGPT's redirect URI first; until then the authorization request is refused and the error message will not explain why.

Why your tool list is shorter than the docs

A well-built remote server filters tools/list by the scopes your token actually holds. If you granted read access, the write and delete tools are not merely rejected when called — they are never advertised. The model cannot attempt them, cannot hallucinate arguments for them, and cannot tell you it “tried but was blocked”.

This is the correct behaviour and it looks like a bug the first time. Before filing one, call the server's capability tool — on c1k.me that is get_capabilities — and compare the effective scope set with what you expected to grant.

While you are there, resist granting more than you need. An assistant that reports on links needs read scopes and nothing else. Destructive scopes for a model should be the exception you can justify out loud, not the default you clicked through.

A debugging table

what you see
what it means
405 Method Not Allowed
The client opened a GET stream. Switch the transport from SSE to HTTP.
400 unsupported version
Version mismatch, or the header was dropped after initialization. Pin the version the server documents.
401 on first contact
Expected. Read the WWW-Authenticate header; the flow starts there. Only a problem if it repeats after consent.
403 on every call
An unapproved Origin header, a suspended account, or a token minted for a different organization or resource.
registration failed
Dynamic client registration is not offered. The client must be preregistered with its exact redirect URI.
works, then dies hourly
Token refresh. Check whether two machines share one config and are rotating the same refresh token.
connected, no tools
Scope filtering working as designed. Check effective scopes before assuming a server fault.

Two habits worth forming

The first is scope discipline. Grant the narrowest set that completes the task, and create a separate connection when an agent genuinely needs more. Revoking one narrow connection is easy; auditing one over-scoped connection after the fact is not.

The second is remembering that tool output is untrusted input. Titles, descriptions and URLs coming back from a remote server originate with people, and sometimes with strangers. A model reading them should treat them as data rather than as instructions. Servers can help — by never embedding credentials in returned URLs, and by shaping output so it does not read as a command — but the boundary is worth holding on your side too.

The short version

Pick the HTTP transport rather than SSE. Send the protocol version header on every request after initialization. Expect the first 401. Do not share one authenticated config between machines. If the tool list looks short, check your scopes before blaming the server. And verify with curl before debugging a client — three commands will tell you which side the problem is on.