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.
Before you expose it
Section titled “Before you expose it”- Authenticate. Set
ARCANE_MCP_HTTP_TOKEN, or useARCANE_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_CERTandARCANE_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=truedeclares that the hop is encrypted in front of it, andARCANE_MCP_HTTP_ALLOW_PLAINTEXT=falsemakes it refuse to start instead (see Plain HTTP and the hardened mode). - Name the hosts. Set
ARCANE_MCP_HTTP_ALLOWED_HOSTSto the names clients use. - Tell it about the proxy. Set
ARCANE_MCP_HTTP_TRUSTED_PROXIESandARCANE_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_USERNAMEandARCANE_PASSWORDunset so each user brings their own key and acts with their own permissions.
Token or API key
Section titled “Token or API key”token (default) | apikey | |
|---|---|---|
| The client sends | Authorization: Bearer <ARCANE_MCP_HTTP_TOKEN> and its Arcane key in X-Arcane-API-Key | Authorization: Bearer <its Arcane API key> |
| Suits | Clients 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 login | Refused at startup |
| Each request costs Arcane | Nothing extra | One 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.
Next to Arcane with Docker Compose
Section titled “Next to Arcane with Docker Compose”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: - arcanePut 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_URLuses the service name and Arcane’s port inside the network (3552by 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 with400. 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), sodocker compose psshows the servicehealthyonce/healthzanswers; nohealthcheck: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.
Behind a reverse proxy
Section titled “Behind a reverse proxy”The proxy terminates TLS and forwards /mcp to the server. What every proxy has to get right:
| Requirement | Why |
|---|---|
Pass the client’s Host, and list it in ARCANE_MCP_HTTP_ALLOWED_HOSTS | With 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 /mcp | Answers 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-Version | All three proxies below pass request headers on unchanged |
| Name the real client | Set 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 headers | The 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.
Traefik
Section titled “Traefik”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: edgekeeps the network’s name as written; without it Compose prefixes the project name andtraefik.docker.network=edgematches nothing (Traefik then answers504).ip_rangekeeps 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-Forthe 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 answers404for 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.
nginx with a unix socket
Section titled “nginx with a unix socket”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:
sudo useradd --system --no-create-home --shell /usr/sbin/nologin arcane-mcpThen, with systemd:
[Unit]Description=arcane-mcp (MCP server for Arcane)After=network-online.targetWants=network-online.target
[Service]ExecStart=/usr/local/bin/arcane-mcpEnvironmentFile=/etc/arcane-mcp/arcane-mcp.envUser=arcane-mcpGroup=www-dataRuntimeDirectory=arcane-mcpRuntimeDirectoryMode=0750Restart=on-failureNoNewPrivileges=yes
[Install]WantedBy=multi-user.target# /etc/arcane-mcp/arcane-mcp.env (mode 0600: it may hold a token)ARCANE_URL=https://arcane.example.comARCANE_MCP_HTTP_ADDR=/run/arcane-mcp/mcp.sockARCANE_MCP_HTTP_AUTH=apikeyARCANE_MCP_HTTP_ALLOWED_HOSTS=mcp.example.comARCANE_MCP_HTTP_TRUSTED_PROXIES=unixARCANE_MCP_HTTP_TRUSTED_PROXY_HEADER=X-Real-IPGroup= 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_ADDR | ARCANE_MCP_HTTP_TRUSTED_PROXIES | Result |
|---|---|---|
| unix socket | unix (with or without addresses) | Starts; the header from the local proxy names each client |
| unix socket | unset | Starts; every client shares the budget unix |
| unix socket | addresses, no unix | Refused at startup: socket callers have no address, so the header would never be believed |
| TCP address | addresses | Starts; the header is believed from those addresses |
| TCP address | unix among the entries | Refused 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.
TLS on the listener
Section titled “TLS on the listener”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
debuglevel.
TLS changes nothing about authentication: a listener without a token still warns that anyone who reaches it can use it.
Plain HTTP and the hardened mode
Section titled “Plain HTTP and the hardened mode”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):
| Value | Startup |
|---|---|
| unset | Served, with a warning that names the ways out: the HTTP listener serves plain HTTP on an address other machines can reach, … |
true | Served, 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) |
false | Refused 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.
Several Arcane instances
Section titled “Several Arcane instances”One server can front several Arcane managers:
environment: ARCANE_URL: home=http://arcane:3552,lab=https://arcane.lab.example.com ARCANE_MCP_HTTP_AUTH: apikeyEach 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.
Stateful or stateless
Section titled “Stateful or stateless”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 theconfirm: truerefusal 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.
Health, readiness and shutdown
Section titled “Health, readiness and shutdown”| Path | Use it for | Answers |
|---|---|---|
/healthz | Liveness: restart the container when it fails | 200 ok while the process serves, whatever Arcane’s state. With ?format=json: status, draining, version, commit, build date and configDigest |
/readyz | Readiness: take the server out of a load balancer when it fails | 200 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) ornot ready;reason, only when not ready, says what the plain form says after thenot 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;nullwhile the version is unknown or is not a release (a development build such asnext).- With several instances,
versionMatchis ininstances, one entry per instance with itsnameandstatus(okor 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 --probeasks the local listener for/healthz(--readyfor/readyz) and exits0on200,1otherwise, 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’sHEALTHCHECKruns it every 30 seconds; a stdio container probes healthy, since it has no listener. A server on port0cannot be found this way: pass the port it logged with--http.- Shutdown. On
SIGTERMthe 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 most5m),SIGTERMfirst turns/readyzto503while 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 raisestop_grace_periodin Compose to the delay plus those 10 seconds.
Logs and the audit trail
Section titled “Logs and the audit trail”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.
Checklist
Section titled “Checklist”-
ARCANE_MCP_HTTP_TOKENorARCANE_MCP_HTTP_AUTH=apikey - TLS at the proxy or on the listener;
/mcprouted without buffering, compression or short timeouts -
ARCANE_MCP_HTTP_ALLOW_PLAINTEXT=truewhen a proxy encrypts the hop to a plain HTTP listener, orfalseto refuse plain HTTP on the network -
ARCANE_MCP_HTTP_ALLOWED_HOSTSwith the public name -
ARCANE_MCP_HTTP_TRUSTED_PROXIES(the proxy’s fixed address, orunix) andARCANE_MCP_HTTP_TRUSTED_PROXY_HEADER - No
ARCANE_API_KEYor 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
-
/readyzwatched, and kept off the public route