# How to connect a remote MCP server to Claude Desktop

> Get a remote MCP server answering tool calls in Claude Desktop, as a custom connector or through a stdio bridge, and probe its transport before blaming the config.

Source: https://voxgig.com/howto/connect-a-remote-mcp-server-to-claude-desktop

- Audience: api-consumer
- Level: beginner
- Verified: 2026-09-25
- Published: 2026-09-25

## Short answer

Add the server's Streamable HTTP URL as a custom connector in Claude Desktop's settings, or, where a connector cannot reach it, put an mcp-remote entry in claude_desktop_config.json and restart the app. Probe the URL first: a server still on the deprecated HTTP+SSE transport answers a Streamable HTTP client with a 405 and nothing more. Make the first target a public, no-auth endpoint, so a transport fault is not mistaken for an auth fault.

---
## You will need

Claude Desktop, a URL for a remote MCP server, and Node 22 or later if you take the `mcp-remote`
route. Verified 2026-09-25 against Node 22.22.2, `@modelcontextprotocol/sdk` 1.30.1,
`mcp-remote` 0.14.3, and `zod` 4.4.2. The first target here is a public endpoint with no
login: [`voxgig.com/mcp`](https://voxgig.com/developers) speaks Streamable HTTP, issues no
session id, and answers `tools/list` to anyone. A failure against it is a transport or config
failure, never an authentication one. The captured output below comes from a stand-in with
the same shape, because the check has to run offline.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A locally installed stdio server](https://modelcontextprotocol.io/docs/2026-07-28/develop/connect-local-servers) | The server ships as a package and works on data on your machine | A runtime to install, a full restart after every config edit, and updates you apply by hand on each machine | The tools live behind a URL someone else runs |
| [Claude Desktop custom connector](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp) | A public Streamable HTTP URL, with OAuth or with no auth at all | The connection starts in Anthropic's cloud, so a private-network URL never connects, and on Team and Enterprise plans only an Owner can add one | The server is on a VPN or on localhost, or your plan's Owner will not add it |
| [`mcp-remote` stdio bridge](https://developers.cloudflare.com/agents/model-context-protocol/guides/test-remote-mcp-server/) | Any stdio-only client, and a URL on any network you can reach from your machine | One more Node process per server, a token cache under `~/.mcp-auth`, and a bridge with flags of its own to debug | The client can connect to the URL itself |

Voxgig maintains the site at voxgig.com, whose `/mcp` endpoint is the first target on this
page. This page compares a custom connector with the `mcp-remote` bridge and a local stdio
server.

A custom connector is one URL typed into a settings screen. The connection it makes starts
in Anthropic's cloud rather than on your laptop, so localhost and VPN addresses are out of
its reach. `mcp-remote` runs where you sit and reaches whatever you can, at the price of
a process and a token cache to debug. A local stdio server needs neither, and needs
installing on every machine.

## Probe the URL before you touch a config file

The [Streamable HTTP specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http)
tells a client how to find out which transport an unknown URL speaks. POST an `initialize`
request with an `Accept` header naming both `application/json` and `text/event-stream`. A
2xx with an initialize result is Streamable HTTP. A 400, 404, or 405 whose body is not a
JSON-RPC error means try a GET. If a stream opens and its first event is `endpoint`, the
server is on the HTTP+SSE transport of 2024-11-05, deprecated since 2025-03-26.[^1] The probe
does exactly that.

```ts title="probe.mjs"
  // A body cut off half way reads as no body: the status alone still decides the fallback.
  const body = await res.text().catch(() => '')
  const err = jsonRpcError(body)
  if (err) return { transport: 'streamable-http', status: res.status, detail: `initialize refused: ${err.message}` }

  if ([400, 404, 405].includes(res.status)) {
    let get
    try {
      get = await fetchImpl(url, { headers: { accept: 'text/event-stream' }, signal: AbortSignal.timeout(timeoutMs) })
    } catch (e) {
      return { transport: 'not-mcp', status: res.status, detail: e.cause?.message ?? e.message }
    }
    if (get.ok && (get.headers.get('content-type') || '').startsWith('text/event-stream')) {
      let first
      try {
        first = await firstEvent(get)
      } catch (e) {
        // A stream that stays silent until the timeout, or breaks, is reported like any other failure.
        return { transport: 'not-mcp', status: res.status, detail: `the GET stream sent no event: ${e.cause?.message ?? e.message}` }
      }
      if (first?.event === 'endpoint') return { transport: 'http+sse (deprecated)', status: res.status, endpoint: first.data }
    } else {
      await get.body?.cancel().catch(() => {})
    }
  }
  return { transport: 'not-mcp', status: res.status }
```

The `jsonRpcError` check before the GET matters. A server on revision 2026-07-28 answers a
version it does not support with a 400 and a JSON-RPC error. The specification says a client
must read that body before falling back, or a modern server reads as a web page.

The demo starts three servers on 127.0.0.1: the stand-in for the remote server, the same
tools on the legacy transport, and a page of HTML. Then it connects to the stand-in both ways
Claude Desktop can.

```bash
node demo.mjs
```

```text output
transport probe
  remote server  transport streamable-http  POST 200  protocol 2025-11-25  server acme-docs 2.1.0  no session id
  legacy server  transport http+sse (deprecated)  POST 405  endpoint event /messages?sessionId=14103df5-523b-4f1f-8095-f3591d08b06c
  a web page     transport not-mcp  POST 200  unexpected content-type text/html

through mcp-remote, as Claude Desktop would start it
  command      npx -y mcp-remote http://127.0.0.1:45101/mcp
  server       acme-docs 2.1.0
  tools        search_docs (read-only), read_doc (read-only)
  search_docs  "api key" -> authentication  Authentication

straight over Streamable HTTP, as a custom connector does
  server       acme-docs 2.1.0
  tools        search_docs (read-only), read_doc (read-only)
  search_docs  "api key" -> authentication  Authentication
  identical    true

a Streamable HTTP client pointed at the legacy server
  Streamable HTTP error: Error POSTing to endpoint: method not allowed

mcp-remote told the transport, pointed at the legacy server
  tools        search_docs (read-only), read_doc (read-only)
```

The legacy line is the pitfall in one row. The POST gets a 405 and no body a client can use,
which is what Claude Desktop's log shows as a failed connection with no further detail. Only
the GET, and the `endpoint` event on the stream it opens, says why. The last block shows the
way through for a server that cannot move yet: `mcp-remote` with `--transport sse-only`
speaks the old transport on the client's behalf.[^2]

Run the probe against the real target before the config file exists. It prints one line and
exits 0 only for Streamable HTTP:

```bash
node probe.mjs https://voxgig.com/mcp
```

## Bridge the URL through a stdio process

Claude Desktop reads its servers from `claude_desktop_config.json`, at
`~/Library/Application Support/Claude/` on macOS and `%APPDATA%\Claude\` on Windows, and the
[local servers guide](https://modelcontextprotocol.io/docs/2026-07-28/develop/connect-local-servers)
documents one entry shape: a `command`, its `args`, and an optional `env`. There is no
documented key for a URL. `mcp-remote` turns a URL into that shape by running as the command
and speaking stdio to the app while it speaks HTTP to the server.

```ts title="config.mjs"
export function bridgeEntry(url, { transport } = {}) {
  const args = ['-y', 'mcp-remote', url]
  // http-first is the default: try Streamable HTTP, fall back to SSE on a 404. A server
  // known to be on the deprecated transport is told so, which saves the failed attempt.
  if (transport) args.push('--transport', transport)
  return { command: 'npx', args }
}
```

The file that results, for a server at `docs.example.com`:

```json
{
  "mcpServers": {
    "acme-docs": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://docs.example.com/mcp"]
    }
  }
}
```

Save it, quit Claude Desktop completely, and start it again. The app reads the file at
launch and never again, so an edit while it runs changes nothing until the next start. Then
open the attachment menu in the conversation input, go to Connectors, and open the server to
see its tools. The config checker in the same file reads a config the way the app does and
names what the app would do with it:

```bash
node demo.mjs
```

```text output
claude_desktop_config.json, as Claude Desktop reads it
  bridge entry ok
  url entry    acme-docs: url is not a documented key here, so nothing runs; add the URL as a custom connector, or bridge it with mcp-remote
  servers key  no mcpServers object: Claude Desktop finds no servers and reports nothing
```

The second and third lines are the two edits that produce an unchanged server list and no
error message. A `url` key is the shape other clients accept for a remote server, and this
file does not. A `servers` key is the shape VS Code writes, and this file reads `mcpServers`.

## Add it as a custom connector

The custom connector is the route with no process to run. In Claude Desktop open Settings,
choose Connectors, click Add and then Add custom connector, and paste the server URL. The
[connector guide](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp)
says the feature is on Free, Pro, Max, Team, and Enterprise plans, and that a Free account
gets one custom connector. On Team and Enterprise plans only an Owner can add one.[^3] A
server that needs OAuth opens its login in the browser at this point, and a server with no
auth connects at once.

One property of this route decides whether it fits at all. The connection to your server is
made from Anthropic's infrastructure, not from your machine, on every client including
Desktop. A URL that resolves only on your VPN, or a server bound to localhost, never
connects this way. `mcp-remote` is the answer for those, because it runs on your side of the
firewall.

## Check it worked

Whichever route you took, the check is the same: the tool list shows the server's tools, and
one read-only call returns data. Against the stand-in that is `search_docs (read-only)` in
the list and `authentication  Authentication` from the call, and the test suite asserts both
routes give the same answers.

```bash
node --test probe.test.mjs
```

```text output
1..14
# tests 14
# suites 0
# pass 14
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 2041.973409
```

Three of the fourteen guard the diagnosis. One holds the probe to the first message on a
stream rather than to its end, because a stateful server may keep the stream open after
the result. One holds the probe to the `endpoint` event, so a 405 from a page that is not MCP at all
is reported as not MCP. The third sends the probe a 400 carrying a JSON-RPC error, as a
2026-07-28 server does, and asserts that no GET follows. Two more cover the probe's failure
paths: a body cut off, a silent stream, and a directory with a space in its path.

## When it goes wrong

The server never connects and `mcp-server-<name>.log` shows a failed POST with a 405. The
server is on the HTTP+SSE transport. Run the probe to confirm, then add `--transport sse-only`
to the `mcp-remote` entry, or ask the server's maintainer for a Streamable HTTP endpoint.

The file was edited and the app shows the old list. Claude Desktop reads the config at
launch. Quit it from the menu, not by closing the window, and start it again. The MCP logs,
`mcp.log` and one `mcp-server-<name>.log` per server, sit under `~/Library/Logs/Claude` on
macOS and `%APPDATA%\Claude\logs` on Windows, and the
[debugging guide](https://modelcontextprotocol.io/docs/2026-07-28/tools/debugging) covers
what each one records.

The log shows `ENOENT` for `npx`. The app could not find it on the path it was started with.
Put the absolute path to `npx` in `command`; the local servers guide has the Windows-specific
fix for `%APPDATA%` in `env`.

A custom connector to a server on your own network times out. The connection comes from
Anthropic's IP ranges, which the connector guide lists a link to, so either allow those
ranges at the firewall or use `mcp-remote` from your machine instead.

## When not to do this

Do not bridge a server that a custom connector can reach. Every `mcp-remote` entry is a Node
process the app starts, a `~/.mcp-auth` directory to clear when a login goes stale, and one
more place a connection can fail. The bridge exists for the case the connector cannot cover,
and it is the wrong default for the case it can.

Do not make a server that needs OAuth your first target. When the login fails you cannot
tell an auth fault from a transport fault, which is the whole reason this page starts from
a public endpoint. The Voxgig site's endpoint is read-only and open, which makes it a fine
first target and nothing more: it is a check on your client, not a tool for your work.

Do not put a bearer token in `args`. Anyone on the machine can read the process list, and
the `mcp-remote` README documents `--header-file` for exactly that reason.

Do not keep a bridge entry after the app grows a route of its own to that server. A bridge
nobody remembers adding is the first suspect when a tool call fails a year later.

## Related how-tos

- [Choose between stdio and Streamable HTTP for an MCP server](/howto/choose-an-mcp-transport)

- [Share one MCP server config across several editors](/howto/share-one-mcp-config-across-editors)

- [Run an MCP server against a mock API while you build tools](/howto/run-an-mcp-server-against-a-mock-api)

## Last verified

Verified 2026-09-25 against Node 22.22.2, `@modelcontextprotocol/sdk` 1.30.1, `mcp-remote`
0.14.3, and `zod` 4.4.2. Every output block is what the command preceding it printed. The
remote server in the output is a stand-in on 127.0.0.1. The probe was also run against
`voxgig.com/mcp`, which answered as Streamable HTTP, protocol 2025-11-25, with no session id.
Claude Desktop itself was not driven by a script: the connector and config steps are the
ones the vendor's documentation gives.

[^1]: The transport has had three shapes in twenty months, and the first is on its way out
with paperwork. The
[2026-07-28 changelog](https://modelcontextprotocol.io/specification/2026-07-28/changelog)
reclassifies HTTP+SSE, deprecated since 2025-03-26, as Deprecated under a
[feature lifecycle policy](https://modelcontextprotocol.io/community/feature-lifecycle)
adopted in the same revision, which sets a minimum deprecation window of twelve months. A
transport deprecated for a year and a half is placed on twelve months' notice. That is the
arithmetic of a governance process that arrives after the fact.

[^2]: The bridge is a package that documents its own retirement. Its
[README](https://raw.githubusercontent.com/geelen/mcp-remote/main/README.md) explains that
most clients were stdio-only when it was written, and that as soon as your chosen client
supports remote, authorized servers, you can remove it. The same README then lists more
than twenty flags, four transport strategies, and a protocol-era mode. That is the usual
fate of a stopgap: it stays until the gap closes, and the gap is in no hurry.

[^3]: The
[connector guide](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp)
is specific about where the connection comes from: Anthropic's cloud infrastructure, on
every client, the desktop app included. It then names the one thing that does use your local
network, the servers in `claude_desktop_config.json`, and adds that those are not available
on the web. A desktop application whose remote connections leave from someone
else's data center, and whose local connections stay home, is two products sharing a
window.