Skip to content

HTTP deployment

Over stdio, each MCP client starts its own arcane-mcp and nothing listens on the network. Run it over HTTP when the client cannot start a local process (remote connectors, web clients), when several people should share one server, or when the server should live next to Arcane rather than on every laptop. Every setting named here is described in Configuration; the threat model is in Security, and the errors you may meet are in Troubleshooting.

  • Authenticate. Set ARCANE_MCP_HTTP_TOKEN, or use ARCANE_MCP_HTTP_AUTH=apikey. Without either, anyone who reaches the port uses the server.
  • Encrypt. The token and the Arcane keys travel in headers. Terminate TLS at a reverse proxy, or give the server a certificate with ARCANE_MCP_HTTP_TLS_CERT and ARCANE_MCP_HTTP_TLS_KEY (TLS on the listener). Plain HTTP is for loopback, a unix socket, or a network only the proxy and the server share: on any other TCP address the server warns at startup, ARCANE_MCP_HTTP_ALLOW_PLAINTEXT=true declares that the hop is encrypted in front of it, and ARCANE_MCP_HTTP_ALLOW_PLAINTEXT=false makes it refuse to start instead (see Plain HTTP and the hardened mode).
  • Name the hosts. Set ARCANE_MCP_HTTP_ALLOWED_HOSTS to the names clients use.
  • Tell it about the proxy. Set ARCANE_MCP_HTTP_TRUSTED_PROXIES and ARCANE_MCP_HTTP_TRUSTED_PROXY_HEADER, or every client shares the proxy’s budget of failed authentications.
  • Decide who acts in Arcane. On a shared server, leave ARCANE_API_KEY, ARCANE_USERNAME and ARCANE_PASSWORD unset so each user brings their own key and acts with their own permissions.
token (default)apikey
The client sendsAuthorization: Bearer <ARCANE_MCP_HTTP_TOKEN> and its Arcane key in X-Arcane-API-KeyAuthorization: Bearer <its Arcane API key>
SuitsClients that can set custom headers (Claude Code, VS Code, Cursor and others; see Client setup)Clients that can only set Authorization: remote connectors, mcp-remote
Server credential (ARCANE_API_KEY, login)Allowed: a client without X-Arcane-API-Key, or with the server’s own key in it, runs as that key and the login. A client with another key runs as that key alone and never gets the loginRefused at startup
Each request costs ArcaneNothing extraOne GET /api/auth/me per key every ARCANE_MCP_HTTP_AUTH_CACHE_TTL (5 minutes)

Both count failed authentications per client address and can rate-limit each key (ARCANE_MCP_HTTP_RATE_LIMIT_RPS). See HTTP authentication.

Add a service to the Compose project that runs Arcane, so the server reaches Arcane by its service name on the project’s network and Arcane’s port need not be published for it:

services:
arcane:
image: ghcr.io/getarcaneapp/manager:v2.17.2
# ... your Arcane service as it is today
arcane-mcp:
image: ghcr.io/jmrplens/arcane-mcp:latest # pin a version tag in production
restart: unless-stopped
environment:
ARCANE_URL: http://arcane:3552
ARCANE_MCP_HTTP_ADDR: ":8080"
ARCANE_MCP_HTTP_ALLOW_PLAINTEXT: "true"
ARCANE_MCP_HTTP_TOKEN: ${ARCANE_MCP_HTTP_TOKEN:?set it in .env}
ARCANE_MCP_HTTP_ALLOWED_HOSTS: localhost,127.0.0.1
ports:
- "127.0.0.1:8080:8080"
depends_on:
- arcane

Put the token in the project’s .env (ARCANE_MCP_HTTP_TOKEN= followed by the output of openssl rand -hex 32). Clients on the same machine connect to http://127.0.0.1:8080/mcp with Authorization: Bearer <token> and their own Arcane key in X-Arcane-API-Key.

  • ARCANE_URL uses the service name and Arcane’s port inside the network (3552 by default); TLS is not needed on a network only the containers of the project share.
  • There is no ARCANE_API_KEY, so a client that sends no key is refused with 400. Add one only if every client of this server should act as the same Arcane user.
  • ARCANE_MCP_HTTP_ALLOW_PLAINTEXT: "true": inside its container the server binds every interface (:8080), so without it startup warns that plain HTTP is served to the network. Here the port is published on the host’s loopback only and the other hop is the project’s own network, so the value says that. The examples behind a proxy below set it for the same reason: the proxy terminates TLS.
  • The port is published on loopback only. Expose the server to others through a reverse proxy (below) or with TLS on the listener, not by publishing plain HTTP on 0.0.0.0:8080.
  • The image has a HEALTHCHECK (arcane-mcp --probe), so docker compose ps shows the service healthy once /healthz answers; no healthcheck: entry is needed. See Health, readiness and shutdown.
  • Until the first release is published, build the image from the repository (docker build -t arcane-mcp .) and use that name.

The proxy terminates TLS and forwards /mcp to the server. What every proxy has to get right:

RequirementWhy
Pass the client’s Host, and list it in ARCANE_MCP_HTTP_ALLOWED_HOSTSWith the list set, /mcp answers only the names in it (403 otherwise). Traefik and Caddy pass Host on; nginx needs proxy_set_header Host $host
No buffering and no compression on /mcpAnswers and server notifications travel as an event stream (text/event-stream). Traefik and Caddy forward it as it arrives; nginx needs proxy_buffering off. Compression holds events back
Response timeouts at least ARCANE_MCP_ACTION_TIMEOUT (30 minutes)A call can run that long, and the server sends no keep-alive pings, so a quiet call or a session’s idle event stream carries no bytes for minutes. nginx cuts it after 60 seconds by default
Bodies up to ARCANE_MCP_HTTP_MAX_BODY_BYTES (16 MiB)An upload chunk is that large once base64-encoded; nginx’s default limit is 1 MiB
Pass Authorization, X-Arcane-API-Key, X-Arcane-URL, Mcp-Session-Id and MCP-Protocol-VersionAll three proxies below pass request headers on unchanged
Name the real clientSet ARCANE_MCP_HTTP_TRUSTED_PROXIES to the proxy’s address as the server sees it (unix for a proxy on a unix socket) and ARCANE_MCP_HTTP_TRUSTED_PROXY_HEADER to the header it fills. The header is believed only from a listed proxy and read from the right, skipping trusted hops
Add no CORS headersThe server answers browser preflights itself for the origins in ARCANE_MCP_HTTP_TRUSTED_ORIGINS; two Access-Control-Allow-Origin headers make the browser refuse the answer
Route /mcp only/healthz and /readyz ask for no credential, and /readyz names the Arcane instances; keep them for your own probes

Give the proxy a fixed address when it shares a Docker network with the server, so that ARCANE_MCP_HTTP_TRUSTED_PROXIES names it and nothing else: a range would let any other container on that network write the header and pick the address its failures are charged to.

With Traefik in the same Compose project, on a network where it has a fixed address:

services:
arcane:
image: ghcr.io/getarcaneapp/manager:v2.17.2
# ... your Arcane service as it is today
traefik:
image: traefik:v3.6
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --entrypoints.websecure.address=:443
- --certificatesresolvers.letsencrypt.acme.tlschallenge=true
- --certificatesresolvers.letsencrypt.acme.email=admin@example.com
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
ports:
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- letsencrypt:/letsencrypt
networks:
edge:
ipv4_address: 172.30.0.2
arcane-mcp:
image: ghcr.io/jmrplens/arcane-mcp:latest
restart: unless-stopped
environment:
ARCANE_URL: http://arcane:3552
ARCANE_MCP_HTTP_ADDR: ":8080"
ARCANE_MCP_HTTP_ALLOW_PLAINTEXT: "true"
ARCANE_MCP_HTTP_AUTH: apikey
ARCANE_MCP_HTTP_ALLOWED_HOSTS: mcp.example.com
ARCANE_MCP_HTTP_TRUSTED_PROXIES: 172.30.0.2
ARCANE_MCP_HTTP_TRUSTED_PROXY_HEADER: X-Forwarded-For
networks: [edge, default]
labels:
- traefik.enable=true
- traefik.docker.network=edge
- traefik.http.routers.arcane-mcp.rule=Host(`mcp.example.com`) && Path(`/mcp`)
- traefik.http.routers.arcane-mcp.entrypoints=websecure
- traefik.http.routers.arcane-mcp.tls.certresolver=letsencrypt
- traefik.http.services.arcane-mcp.loadbalancer.server.port=8080
networks:
edge:
name: edge
ipam:
config:
- subnet: 172.30.0.0/24
ip_range: 172.30.0.128/25 # dynamic addresses stay clear of .2
volumes:
letsencrypt:

This example uses apikey mode: each user connects to https://mcp.example.com/mcp with their own Arcane API key as the Bearer token, and there is no shared secret to hand out.

  • name: edge keeps the network’s name as written; without it Compose prefixes the project name and traefik.docker.network=edge matches nothing (Traefik then answers 504).
  • ip_range keeps the addresses Docker hands out to other containers away from Traefik’s, which a container started first could otherwise take.
  • Traefik replaces an X-Forwarded-For the client sent with the address it saw, so the server charges failed authentications to the real client.
  • Traefik 3.5 (checked with 3.5.6) cannot read Docker Engine 29 (client version 1.24 is too old) and answers 404 for every route; 3.6 can.

With Caddy in the same Compose project, also with a fixed address:

services:
arcane:
image: ghcr.io/getarcaneapp/manager:v2.17.2
# ... your Arcane service as it is today
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
networks:
default:
ipv4_address: 172.31.0.2
arcane-mcp:
image: ghcr.io/jmrplens/arcane-mcp:latest
restart: unless-stopped
environment:
ARCANE_URL: http://arcane:3552
ARCANE_MCP_HTTP_ADDR: ":8080"
ARCANE_MCP_HTTP_ALLOW_PLAINTEXT: "true"
ARCANE_MCP_HTTP_TOKEN: ${ARCANE_MCP_HTTP_TOKEN:?set it in .env}
ARCANE_MCP_HTTP_ALLOWED_HOSTS: mcp.example.com
ARCANE_MCP_HTTP_TRUSTED_PROXIES: 172.31.0.2
ARCANE_MCP_HTTP_TRUSTED_PROXY_HEADER: X-Forwarded-For
networks:
default:
ipam:
config:
- subnet: 172.31.0.0/24
ip_range: 172.31.0.128/25 # dynamic addresses stay clear of .2
volumes:
caddy_data:

and the Caddyfile next to it:

mcp.example.com {
handle /mcp {
reverse_proxy arcane-mcp:8080
}
handle {
respond 404
}
}

Caddy gets the certificate itself, streams event streams as they arrive and has no response timeout of its own. Do not add encode to this site. This example keeps token mode: clients send the token and their own Arcane key.

When nginx runs on the host, the server can listen on a unix socket instead of a port: nothing else on the host reaches it but processes the socket’s permissions let in, and no port is left open. Run the binary as a service, under a system user of its own:

Terminal window
sudo useradd --system --no-create-home --shell /usr/sbin/nologin arcane-mcp

Then, with systemd:

/etc/systemd/system/arcane-mcp.service
[Unit]
Description=arcane-mcp (MCP server for Arcane)
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/local/bin/arcane-mcp
EnvironmentFile=/etc/arcane-mcp/arcane-mcp.env
User=arcane-mcp
Group=www-data
RuntimeDirectory=arcane-mcp
RuntimeDirectoryMode=0750
Restart=on-failure
NoNewPrivileges=yes
[Install]
WantedBy=multi-user.target
Terminal window
# /etc/arcane-mcp/arcane-mcp.env (mode 0600: it may hold a token)
ARCANE_URL=https://arcane.example.com
ARCANE_MCP_HTTP_ADDR=/run/arcane-mcp/mcp.sock
ARCANE_MCP_HTTP_AUTH=apikey
ARCANE_MCP_HTTP_ALLOWED_HOSTS=mcp.example.com
ARCANE_MCP_HTTP_TRUSTED_PROXIES=unix
ARCANE_MCP_HTTP_TRUSTED_PROXY_HEADER=X-Real-IP

Group= is the group nginx runs as (www-data on Debian and Ubuntu). The socket is created with ARCANE_MCP_HTTP_SOCKET_MODE (0660), owned by the service’s user and that group, so nginx can connect and other users cannot; it is removed when the service stops. Then the site:

upstream mcp_backend {
server unix:/run/arcane-mcp/mcp.sock;
}
server {
listen 443 ssl;
http2 on;
server_name mcp.example.com;
ssl_certificate /etc/letsencrypt/live/mcp.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mcp.example.com/privkey.pem;
location = /mcp {
proxy_pass http://mcp_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Connection "";
# Answers are event streams: pass them on as they come.
proxy_buffering off;
gzip off;
# A call may run for ARCANE_MCP_ACTION_TIMEOUT (30m) without a byte.
proxy_read_timeout 35m;
proxy_send_timeout 35m;
# The server takes bodies up to ARCANE_MCP_HTTP_MAX_BODY_BYTES (16 MiB).
client_max_body_size 16m;
}
location / {
return 404;
}
}

A connection on a socket has no client address. ARCANE_MCP_HTTP_TRUSTED_PROXIES=unix tells the server to believe the X-Real-IP nginx sets from the address it saw; without it, every client shares one auth-failure budget under the name unix.

The listener and the trusted proxies must match, or every caller would silently share one budget; the server refuses to start on the two pairings that would do that:

ARCANE_MCP_HTTP_ADDRARCANE_MCP_HTTP_TRUSTED_PROXIESResult
unix socketunix (with or without addresses)Starts; the header from the local proxy names each client
unix socketunsetStarts; every client shares the budget unix
unix socketaddresses, no unixRefused at startup: socket callers have no address, so the header would never be believed
TCP addressaddressesStarts; the header is believed from those addresses
TCP addressunix among the entriesRefused at startup: unix trusts nobody on TCP

The same works with Caddy on the host (reverse_proxy unix//run/arcane-mcp/mcp.sock, and ARCANE_MCP_HTTP_TRUSTED_PROXY_HEADER=X-Forwarded-For). In a container the socket goes in a volume both containers mount; the image runs as user and group 65532, so give the server a group the proxy also has (user: "65532:<proxy's group id>") or a wider ARCANE_MCP_HTTP_SOCKET_MODE.

Without a proxy, the server can speak HTTPS itself:

services:
arcane:
image: ghcr.io/getarcaneapp/manager:v2.17.2
# ... your Arcane service as it is today
arcane-mcp:
image: ghcr.io/jmrplens/arcane-mcp:latest
restart: unless-stopped
environment:
ARCANE_URL: http://arcane:3552
ARCANE_MCP_HTTP_ADDR: ":8443"
ARCANE_MCP_HTTP_AUTH: apikey
ARCANE_MCP_HTTP_TLS_CERT: /certs/fullchain.pem
ARCANE_MCP_HTTP_TLS_KEY: /certs/privkey.pem
ARCANE_MCP_HTTP_ALLOWED_HOSTS: mcp.example.com
volumes:
- /etc/arcane-mcp/certs:/certs:ro # a directory, so that renewed files are seen
ports:
- "8443:8443"

Clients connect to https://mcp.example.com:8443/mcp.

  • TLS 1.2 or later, with the PEM certificate (the full chain) and key named by the two settings. A missing, unreadable or mismatched pair stops the server at startup.
  • Renewals without a restart. The files are checked again at most once a second as connections arrive; a renewed pair is served from the next handshake and logged (reloaded the TLS certificate). A pair that cannot be loaded (half written, key and certificate from different renewals) keeps the previous certificate and is logged once.
  • Mount a directory, not the two files. Most renewal tools replace a file by renaming a new one over it, and a bind-mounted file keeps showing the old one.
  • The image runs as user 65532, so the key must be readable by it. certbot writes keys readable only by root: copy the pair into the mounted directory from a deploy hook (--deploy-hook) and give it to that user.
  • No proxy, no trusted proxies. Each client is charged its own failures. Docker’s published ports keep the client’s address for connections from other machines.
  • Failed handshakes are throttled in the log: port scanners, plain HTTP sent to the HTTPS port and clients that refuse the certificate are logged as one warning per minute with the number left out, and each one at debug level.

TLS changes nothing about authentication: a listener without a token still warns that anyone who reaches it can use it.

At startup the server looks at ARCANE_MCP_HTTP_ADDR. A TCP address other machines can reach (a wildcard bind such as :8080, 0.0.0.0:8080 or [::]:8080, a routable address, or a name that resolves to anything but loopback) served without TLS on the listener carries every token, Arcane key and answer in the clear, whatever the authentication mode. What happens then is ARCANE_MCP_HTTP_ALLOW_PLAINTEXT (or --http-allow-plaintext, which wins over it):

ValueStartup
unsetServed, with a warning that names the ways out: the HTTP listener serves plain HTTP on an address other machines can reach, …
trueServed, with a line at INFO: you state that something in front encrypts the hop (a proxy that terminates TLS and reaches the server over a private network or a container bridge)
falseRefused before the port is bound, exit status 1: serve HTTP: ARCANE_MCP_HTTP_ALLOW_PLAINTEXT=false (--http-allow-plaintext=false) refuses a listener that serves plain HTTP …

TLS on the listener, a loopback address (or a name that resolves only to loopback) and a unix socket log nothing and start with any value, so the hardened mode suits a server that should never serve plain HTTP to the network by mistake: give it a certificate, bind 127.0.0.1 for a proxy on the same machine, or use a socket. A container cannot use loopback for a proxy in another container (inside its own network namespace, 127.0.0.1 is the container itself), so in the hardened mode a containerized server needs TLS on the listener or a socket in a shared volume.

One server can front several Arcane managers:

environment:
ARCANE_URL: home=http://arcane:3552,lab=https://arcane.lab.example.com
ARCANE_MCP_HTTP_AUTH: apikey

Each request names its instance in X-Arcane-URL (home or lab); a request without it is refused with 400, an unlisted name with 403. Keys belong to one Arcane, so in token mode ARCANE_API_KEY, when set, is a name=key list, and a login (ARCANE_USERNAME, ARCANE_PASSWORD) stops the server, since it would serve one instance only. A user who works with both configures two client entries with different X-Arcane-URL headers. /readyz checks every instance and answers degraded while some are down. See Several instances.

Keep the default, stateful transport unless you have a reason to change it:

  • Stateful (default): sessions with Mcp-Session-Id, bound to the key that opened them; destructive calls can ask the user through elicitation on every protocol revision. Clients that speak protocol 2026-07-28 are told to fall back to 2025-11-25 and keep working.
  • Stateless (ARCANE_MCP_HTTP_STATELESS=on): no sessions, every POST stands alone, so any replica behind a load balancer can answer any request. Clients on revisions before 2026-07-28 lose elicitation and get the confirm: true refusal instead.

With the stateful transport and more than one replica, the load balancer must send all requests of a session to the replica that opened it; a replica that does not know the session answers 404 and the client starts a new one. See Stateless HTTP.

PathUse it forAnswers
/healthzLiveness: restart the container when it fails200 ok while the process serves, whatever Arcane’s state. With ?format=json: status, draining, version, commit, build date and configDigest
/readyzReadiness: take the server out of a load balancer when it fails200 ready when Arcane answers GET /api/health; 503 not ready: … when it does not, while draining and while the server shuts down. With several instances, 200 degraded while some answer, followed by one name: ok or name: <what failed> line each. With ?format=json: the same status code, with whether Arcane’s version matches the catalog’s (below)

Neither asks for the token or touches /mcp, and the readiness check is cached for 5 seconds, so probes cannot flood Arcane. /readyz names instances to anyone who reaches it (never their URLs); keep it off the public route, as the examples above do. Two replicas with the same configDigest were started with the same settings.

/readyz?format=json (or an Accept of application/json alone, as for /healthz) answers with the plain form’s status code and these fields:

  • status: ready, degraded (several instances, some down) or not ready; reason, only when not ready, says what the plain form says after the not ready: prefix.
  • catalogVersion: the Arcane release the catalog was generated from.
  • versionMatch: whether the major.minor of the version the instance reported is the catalog’s; null while the version is unknown or is not a release (a development build such as next).
  • With several instances, versionMatch is in instances, one entry per instance with its name and status (ok or what failed).
{"status":"ready","versionMatch":false,"catalogVersion":"v2.17.2"}

The exact Arcane version is not in the answer: /readyz asks for no credential, and the version would let anyone who reaches the server fingerprint the Arcane behind it. It is in the server’s log, in the warning and info lines described in Arcane’s version differs from the catalog’s. A version that differs from the catalog’s is logged as a warning and shows here as versionMatch: false, but never makes /readyz fail. The plain form never asks Arcane for its version; the JSON form reads it again when the one it has is older than 10 minutes.

  • arcane-mcp --probe asks the local listener for /healthz (--ready for /readyz) and exits 0 on 200, 1 otherwise, within 3 seconds (--timeout). It reads the address and the certificate the way the server does, so it finds a TCP port, a TLS listener or a unix socket without arguments. The image’s HEALTHCHECK runs it every 30 seconds; a stdio container probes healthy, since it has no listener. A server on port 0 cannot be found this way: pass the port it logged with --http.
  • Shutdown. On SIGTERM the server stops accepting connections, ends open event streams, and gives requests in flight up to 10 seconds to finish.
  • Draining. With ARCANE_MCP_HTTP_DRAIN_DELAY (at most 5m), SIGTERM first turns /readyz to 503 while the server keeps serving, so the load balancer stops sending new requests; the shutdown above runs after the delay. Docker waits 10 seconds before it kills a container, so raise stop_grace_period in Compose to the delay plus those 10 seconds.

Everything goes to standard error: docker logs or docker compose logs arcane-mcp for a container, journalctl -u arcane-mcp for the service above. Each tool call leaves one audit record with the key’s fingerprint and, with several instances, the instance name; refusals at the HTTP layer are logged at warn and failed authentications at info, with the client address. Ship standard error wherever you keep logs if you need a trail. See Logs and audit trail.

  • ARCANE_MCP_HTTP_TOKEN or ARCANE_MCP_HTTP_AUTH=apikey
  • TLS at the proxy or on the listener; /mcp routed without buffering, compression or short timeouts
  • ARCANE_MCP_HTTP_ALLOW_PLAINTEXT=true when a proxy encrypts the hop to a plain HTTP listener, or false to refuse plain HTTP on the network
  • ARCANE_MCP_HTTP_ALLOWED_HOSTS with the public name
  • ARCANE_MCP_HTTP_TRUSTED_PROXIES (the proxy’s fixed address, or unix) and ARCANE_MCP_HTTP_TRUSTED_PROXY_HEADER
  • No ARCANE_API_KEY or login on a shared server, or a key with a narrow role
  • Read-only mode, safe mode or exclusions if every user should be limited (they apply to the whole server)
  • Standard error collected, for the audit log
  • /readyz watched, and kept off the public route