MCP server¶
New in 2.1.
Settings › MCP server lets an AI assistant — Claude Code, Codex, Antigravity, or any other Model Context Protocol client — read this node and, when you allow it, change it. Ask "why is the Jellyfin service down?" and the assistant can read the statistics, the URL probes and the logs; ask it to publish a service and it fills in the same wizard the UI does.
Every tool is a call to the node's own API, made inside the app. So an
assistant can do nothing the UI could not, and nothing the UI would refuse: the
same validation, the same haproxy -c before a change is stored, the same lock
on a passive node, and a line in the log naming the client. Nothing it changes
goes live until it calls apply_changes — the same Apply as the button.
It is off until you turn it on, and read-only until you allow changes.
Turning it on¶
- Open Settings › MCP server, tick Answer MCP clients at /mcp, and press Save. The pill in the header says on, read-only.
- Under Token, press Make a token. The card then shows its fingerprint and when it was made.
- Under Connect a client, pick your client and copy what it shows into it. See connecting a client.
- Tick Allow changes and Save again only if you want the assistant to change things, not only read them. See reading and changing.
The token is kept, so it can be shown again for the next client you set up:
- Show the token puts it on the page, and fills it into the client text below; Hide the token takes it away again.
- Copy copies it without showing it.
- Replace the token makes a new one. The old one stops working at that moment, for every client using it, so each needs the new one.
A token made on 2.1.0-beta.1 was kept only as a hash and cannot be shown; the card says so. Press Replace the token once to get one that can.
The server answers at /mcp on the UI's own address — http://<node>:8080/mcp,
or https://<name>/mcp once the UI is
served through HAProxy. Use the
HTTPS name: over plain HTTP the token crosses the network in the clear, exactly
as a password would.
Connecting a client¶
The Connect a client card has a picker with each client below. It shows what that client needs, with the address the page was opened at and, once the token is shown, the token filled in; its Copy button copies the text with the token in it. Open the page through the virtual IP or the UI's public name, and the text points the client there, so it reaches whichever node is active (see in a cluster).
The same text is below, with https://proxy.example.net/mcp for the address
and <token> for the token. Where it goes into a file, merge it with what is
already there, then restart the program.
The token is in plain text
Each of these leaves the token in a configuration file on your computer. Keep those files out of repositories, screenshots and anything shared.
Run in a terminal. Every Claude Code session started after this has the tools, whatever folder it starts in.
claude mcp add --scope user --transport http haproxy-manager https://proxy.example.net/mcp --header "Authorization: Bearer <token>"
/mcp inside Claude Code then lists haproxy-manager with its tools.
Add to claude_desktop_config.json (Settings › Developer › Edit
Config), then restart Claude Desktop. It connects through
mcp-remote, a small bridge
run with npx, so Node.js 18 or later is needed.
{
"mcpServers": {
"haproxy-manager": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://proxy.example.net/mcp",
"--header",
"Authorization:${AUTH_HEADER}"
],
"env": {
"AUTH_HEADER": "Bearer <token>"
}
}
}
}
Add to ~/.codex/config.toml, then restart Codex. The CLI, the IDE
extension and the app all read this file.
[mcp_servers.haproxy-manager]
url = "https://proxy.example.net/mcp"
http_headers = { "Authorization" = "Bearer <token>" }
Add to ~/.gemini/config/mcp_config.json (MCP Servers › Manage MCP
Servers › View raw config), then restart Antigravity.
{
"mcpServers": {
"haproxy-manager": {
"serverUrl": "https://proxy.example.net/mcp",
"headers": {
"Authorization": "Bearer <token>"
}
}
}
}
Run in a terminal.
gemini mcp add -s user --transport http --header "Authorization: Bearer <token>" haproxy-manager https://proxy.example.net/mcp
Add to ~/.openclaw/openclaw.json, then restart the OpenClaw gateway.
{
"mcp": {
"servers": {
"haproxy-manager": {
"url": "https://proxy.example.net/mcp",
"transport": "streamable-http",
"headers": {
"Authorization": "Bearer <token>"
}
}
}
}
}
Add to ~/.cursor/mcp.json, then restart Cursor.
{
"mcpServers": {
"haproxy-manager": {
"url": "https://proxy.example.net/mcp",
"headers": {
"Authorization": "Bearer <token>"
}
}
}
}
Add to mcp.json (MCP: Open User Configuration), then restart VS
Code.
{
"servers": {
"haproxy-manager": {
"type": "http",
"url": "https://proxy.example.net/mcp",
"headers": {
"Authorization": "Bearer <token>"
}
}
}
}
Run in a terminal.
copilot mcp add --transport http --header "Authorization: Bearer <token>" haproxy-manager https://proxy.example.net/mcp
Formerly Windsurf. Add to ~/.config/devin/mcp_config.json, then restart
Devin Desktop.
{
"mcpServers": {
"haproxy-manager": {
"url": "https://proxy.example.net/mcp",
"headers": {
"Authorization": "Bearer <token>"
}
}
}
}
Add to settings.json (zed: open settings), then restart Zed.
{
"context_servers": {
"haproxy-manager": {
"url": "https://proxy.example.net/mcp",
"headers": {
"Authorization": "Bearer <token>"
}
}
}
}
Run in a terminal.
cline mcp add haproxy-manager https://proxy.example.net/mcp --transport http --header "Authorization: Bearer <token>" --yes
Run in a terminal.
opencode mcp add haproxy-manager --global --url https://proxy.example.net/mcp --header 'Authorization=Bearer <token>'
Any client that can send a header to a Streamable HTTP server. It needs two things:
URL: https://proxy.example.net/mcp
Header: Authorization: Bearer <token>
A client that only starts local (stdio) servers needs a bridge between the
two, such as mcp-remote as Claude Desktop uses it.
ChatGPT and claude.ai's connectors do not work. They sign in with OAuth only, which this server does not offer — it knows only its own token — and they connect from their provider's cloud, so the node would have to be reachable from the internet.
What the server implements is the part a tool server needs: initialize,
ping, tools/list and tools/call, over POST /mcp, answered as plain
JSON. There is no event stream, so GET /mcp answers 405. It accepts the
protocol versions from 2024-11-05 to 2025-11-25.
A conversation¶
What it is like, in Claude Code with the server connected and Allow changes on. The services and numbers are an example; the tool calls are the ones the assistant makes, and the answers are put together from what they return. Wording varies from one conversation to the next.
list all my services on haproxy
You have four services, all HTTPS with HTTP redirected to them:
| Service | Address | Forwards to | Certificate |
|---|---|---|---|
| jellyfin | https://jellyfin.example.com |
192.168.1.20:8096 |
valid, 71 days |
| homeassistant | https://homeassistant.example.com |
192.168.1.30:8123 |
valid, 71 days |
| nextcloud | https://nextcloud.example.com |
192.168.1.40:80 |
valid, 71 days |
| grafana | https://grafana.example.com |
192.168.1.50:3000 |
valid, 71 days |
None is paused, and nothing is waiting to be applied.
which service is busiest?
Over the last 24 hours, jellyfin, by a wide margin:
- jellyfin: 18,420 requests and 41.2 GB sent, most of it in the evening.
- homeassistant: 9,870 requests, but only 310 MB; the app polls all day.
- nextcloud: 1,530 requests, 2.1 GB; 14 of them were 5xx errors, all around 03:10.
- grafana: 312 requests.
Want me to look at the nextcloud errors in the logs?
please rename my grafana service to dashboards
Done. The Backend Pool is now dashboards, and the rule that routes to it follows the new name; its server and certificate are reused as they were. HAProxy accepted the result in the dry run first.
The address is still https://grafana.example.com: renaming the service
does not change it. Say if you want the address changed too.
This is stored but not live yet; it goes live on Apply.
change the backend port for my homeassistant website
homeassistant forwards to 192.168.1.30:8123. Which port should it use?
8124
Changed: homeassistant now forwards to 192.168.1.30:8124. Everything else
about it is as it was: the address, the certificate, the health check.
Two changes are waiting now, this one and the rename. Shall I apply them?
yes
Applied: HAProxy checked the new configuration and reloaded (in a cluster,
the other nodes get it too). I probed every address afterwards and all four answer,
https://homeassistant.example.com included, so the new port is the right
one.
Things to notice:
- It asks when the request leaves something out. "Change the port" did not say to what, so it asked rather than guessed.
- A dry run comes first. The tools that have one show the lines a change becomes, and HAProxy's verdict on them, before anything is stored.
- Nothing is live until Apply, and the server's instructions tell the assistant to ask before applying, restoring a snapshot or deleting. Claude Code also asks your permission before each tool it calls, unless you have allowed that tool for good.
- The log names the client. Each change above is in the node's log as
mcp:claude-code, like a change made in the UI is under your name.
The tools¶
The MCP server page links here rather than listing them. Reads tools work whenever the server is on; changes tools need Allow changes, except as a dry run.
| Tool | What it does | Kind | Dry run |
|---|---|---|---|
get_status |
This node: HAProxy and Keepalived state, cluster role and virtual IPs, whether there are unapplied changes, certificates and their expiry, CrowdSec, updates | reads | |
list_services |
Every published service: URLs, targets, health, certificate, sign-in, allow-lists, rules, HSTS, redirects, paused state | reads | |
get_service |
One service, by its id, pool name or public URL | reads | |
list_objects |
The objects of one collection under Advanced · HAProxy or ACME, as stored | reads | |
get_settings |
One section of settings: haproxy, acme, cluster, crowdsec, notify or watchdog | reads | |
get_haproxy_config |
The haproxy.cfg the stored configuration renders to — what Apply would write |
reads | |
check_config |
haproxy -c on that file, storing nothing |
reads | |
get_stats |
HAProxy's live counters per frontend, backend and server | reads | |
get_traffic |
Requests, bytes and errors over time, for every pool or one | reads | |
get_probes |
The latest outside check of every published URL | reads | |
get_logs |
Recent log lines from the app, HAProxy, Keepalived, acme.sh or CrowdSec | reads | |
get_cluster |
Every node as this one last saw it | reads | |
list_history |
The configuration snapshots, newest first | reads | |
get_history_diff |
What differs between a snapshot and now | reads | |
get_vip_status |
Why this node does or does not hold the virtual IP: Keepalived's VRRP state and where it was read from, the virtual IPs and which it holds, the interface, keepalived -t on what Apply would write, and the journal's last lines. Ask each node for its own |
reads | |
check_certificate_setup |
Whether acme.sh is installed, runs and can store keys and certificates on this node, with the problem and the fix when it cannot | reads | |
find_certificate |
Which configured certificate, if any, already covers a host name or URL — exactly or by a wildcard — with its status and days left. publish_service reuses it |
reads | |
list_recipes |
The recipes: ready-made settings for common applications, which can be passed to publish_service |
reads | |
run_probes |
Checks every published URL the way a browser would (DNS, connection, certificate, status) now instead of at the next minute, and returns the results as get_probes does. Useful right after apply_changes. Runs on the node holding the virtual IP or a standalone node; a passive node says so, since the names lead to the virtual IP. Changes no configuration |
reads | |
get_certificate_log |
acme.sh's output from a certificate's last issue or renewal | reads | |
publish_service |
Publish a service — forwarded or a redirect — or edit one by service_id; fields left out keep their values |
changes | yes |
pause_service |
Pause or resume a service | changes | |
delete_service |
Remove a service and the objects only it used | changes | |
save_rule |
Create or edit a rule, with its conditions and listeners | changes | yes |
save_condition |
Create or edit a condition, optionally testing a sample request against it | changes | yes |
request_certificate |
Create a certificate for some names and issue it through ACME | changes | yes |
issue_certificate |
Issue or renew an existing certificate now | changes | |
renew_certificates |
What the daily renewal does, now: every auto-renewing certificate that is due is renewed. Uploaded certificates are never renewed. Runs on the node holding the virtual IP | changes | |
create_object |
Add an object to a collection | changes | haproxy/ only |
update_object |
Replace an object with a changed copy of it | changes | haproxy/ only |
delete_object |
Remove one object from a collection | changes | |
update_settings |
Change haproxy or acme settings, only the keys given; refused if HAProxy would not accept the result | changes | |
restore_history |
Put the configuration back to a snapshot | changes | |
apply_changes |
Write haproxy.cfg, validate, reload and push to the other nodes — the step that makes changes live |
changes |
Each tool carries the MCP hints a client uses to decide what to ask you about:
the reading ones are marked read-only, and delete_service, update_object,
delete_object, restore_history and apply_changes destructive.
There is no tool to upload or download a certificate or its private key, none
for the login, the API key, the peers or the MCP server's own settings and
token, and list_objects reaches only the HAProxy and ACME collections.
Reading and changing¶
With Allow changes off, every reading tool works and every changing tool
is refused, with a message saying where the switch is. The tools with a
dry run still answer when given dry_run: true, and store nothing:
publish_service,save_rule,save_conditionandrequest_certificatereturn the configuration lines the change would produce and HAProxy's verdict on them.create_objectandupdate_objectreturn the lines the object renders to inhaproxy.cfgandhaproxy -con the whole file with it in. That works for thehaproxy/collections only; for anacme/collection, which renders nothing intohaproxy.cfg, the dry run is refused.
That is the way to let an assistant draft a change for you to make. A
dry_run given to a changing tool that has none is not a dry run: it is
refused like any other change while changes are off.
With it on, the assistant can publish, edit and delete, and Apply. A change is
stored first and goes live only at apply_changes, and get_status says
whether anything is waiting, so the order is the one the UI follows: change,
check, apply. The server tells the client so when it connects, and asks it to
prefer a dry run first where there is one.
In a cluster¶
The cluster behaves as one server:
- The settings and the token are the same everywhere. Saving on the MCP server page, or making a token, copies the switches and the token to every other node, over the peer link with each node's API key. A node that did not take it is named on the page — change it there too, or save again once it answers. A node added later gets them the next time you save.
- A change asked of a passive node is made on the active one. A changing
tool called on a node that does not hold the virtual IP is passed to the node
that does, and the answer starts with (made on proxy1, which holds the
virtual IP; this node is passive). If no node answers as the one holding it,
the change is refused rather than made somewhere it would be overwritten.
renew_certificatesgoes the same way, since only that node renews. - Reading stays local. A reading tool answers from the node asked, so
get_statusorget_vip_statuson a passive node describes that node.run_probesis refused there: the published names lead to the virtual IP, so only the node holding it probes them.
Point the client at the virtual IP, or at the UI's public name, so it always reaches whichever node is active.
Security¶
- Its own token.
/mcpdoes not accept the API key or a UI session, only the MCP token, sent asAuthorization: Bearer. A request is checked against the token's SHA-256 hash; the token itself is kept beside it inconfig.json, as the API key is, so the page can show it again. Anyone signed in as the administrator, or holding the API key, can have it shown, and showing it is logged. The page shows an eight-character fingerprint and the date it was made. A token handed to an assistant can be replaced without touching the API key the cluster depends on. - Secrets are kept back. Anything whose name looks like a secret —
passwords, tokens, keys, credentials, secrets, salts, hashes, TOTP — is
returned as
[redacted], at any depth. Sending[redacted]back inupdate_objectkeeps the stored value, so an object read and written back does not lose its DNS credentials or client secret. No tool returns the MCP token. - No other site's page can use it. A request carrying an
Originheader for any host other than the one it was sent to is refused, so a web page cannot reach a node through a rebound DNS name. - Everything is in the log. Each tool call is logged with the client's
name and its arguments (without secret-looking ones), and every change it
makes is in the audit log as
mcp:<client name>— the name the client gave when it connected, such asmcp:claude-code. A change forwarded from a passive node addsvia <address>. A request without a valid token is logged as a warning, and so is making a new token. - Off and read-only by default, and a node's settings never travel in a backup or a history snapshot: they are per node, copied only by the page itself.
- What a tool returns goes to the model's provider. The assistant reads the results to answer you, so service names, addresses, log lines and the rendered configuration leave the node for wherever the model runs. Turn the server on only where that is acceptable, and turn it off again when it is not in use.