Skip to main content
Glama

Haio Ticket MCP Server

A Model Context Protocol (MCP) server that exposes the Haio legacy ticket system and user-product catalog to AI agents. It talks directly to the production MySQL database (backend on api.haio.ir) and is reverse-proxied through nginx at https://api.haio.ir/mcp-ticket.

The server is a thin, read/write wrapper around the same MySQL tables that the Lumen/PHP backend writes to. An AI agent can list tickets, read a ticket with its full comment thread, reply as a support engineer, change status/label/ category, look up a user, and see exactly which products (servers, DNS, email, anti-sanction packages, HaioClouds, services, ...) that user currently has.


Quick reference

Item

Value

Endpoint URL

https://api.haio.ir/mcp-ticket

Transport

MCP streamable-HTTP (POST JSON; Accept: application/json, text/event-stream)

Auth header

Authorization: Bearer <MCP_TOKEN>

Server host

api.haio.ir (94.182.172.77) — runs as systemd ticket-mcp.service on port 8801

Database

MySQL backend on 127.0.0.1:3306, user mcp (limited grants)

OpenChamber

Already registered as haio-ticket in opencode.json of the production instance

Source code

server.py (this directory) — single file, FastMCP + pymysql + uvicorn

Current production token (rotate via §6 if you need a new one):

8d16eb892390eccd7f6f187988ee4ee564e918e5fe01978d

1. Architecture

  AI agent / opencode
        │
        │  POST https://api.haio.ir/mcp-ticket
        │  Authorization: Bearer <TOKEN>
        │  Content-Type: application/json
        │  Accept: application/json, text/event-stream
        ▼
  nginx (api.haio.ir:443)
        │  location = /mcp-ticket → proxy_pass http://127.0.0.1:8801/mcp
        ▼
  ticket-mcp.service (uvicorn + FastMCP, 127.0.0.1:8801)
        │  pymysql → 127.0.0.1:3306
        ▼
  MySQL `backend` (mariadbd)
        │  tables: tickets, ticket_comments, users, wallets,
        │          services, haioflash_vms, cloud_vms, anti_sanctions,
        │          user_projects, haioclouds, dns_domains,
        │          email_domains, email_accounts, ticket_categories,
        │          ticket_labels, ticket_statuses
        ▼
   ticket + user + product data

Key design choices:

  • Direct MySQL, not HTTP to the Lumen app. The Lumen/PHP API would force the agent to re-authenticate as an admin and fight Lumen's CSRF. The DB is the source of truth and is already the place reply writes go.

  • Dedicated DB user mcp with SELECT on backend.* and SELECT,INSERT, UPDATE on tickets + ticket_comments only. The server cannot accidentally drop a column or rewrite users.

  • Bearer token, not OAuth. MCP's streamable-HTTP profile doesn't require OAuth; a static token in Authorization: Bearer … is enough for an internal tool, and rotating it is one systemd restart.

  • enable_dns_rebinding_protection=False in FastMCP. The SDK enforces Host/Origin checks by default in 1.29+; with the public DNS name api.haio.ir and a TLS-terminating nginx, the default check rejects every request with 421 Invalid Host header. We disable it; the Bearer token is the actual security boundary.


2. Tools exposed (10)

All tools are namespaced under the MCP server name haio-ticket.

Tickets

Tool

Args

Purpose

ticket_statuses

Static map of status IDs → Persian labels (1=جدید … 7=بسته شده).

ticket_categories

Active categories from ticket_categories (cat_status=1).

ticket_labels

All labels from ticket_labels.

ticket_list

search?, status_id?, category_id?, page?, per_page?

Paginated list, JOIN users, includes status_title and user_name.

ticket_get

ticket_id

Ticket header + full comment thread (oldest → newest) + user summary.

ticket_reply

ticket_id, body

Insert as کارشناس (user_id=33, comment_side=2), set status_id=4 (پاسخ کارشناس), and claim the ticket by setting agent_id and catch_ticket to MCP_AGENT_USER_ID — mirroring the Lumen TicketController::reply flow so the ticket shows up in the agent's queue.

ticket_update

ticket_id, status_id?, label_id?, category_id?

Change one or more of the three fields.

Users

Tool

Args

Purpose

user_search

query, limit?

LIKE on name/first_name/last_name/email/mobile (deleted_at IS NULL).

user_profile

user_id

Profile row + every wallet balance (wallets WHERE holder_type='App\\Models\\User').

user_products

user_id

Dict of product lists: services, haioflash_vms, cloud_vms, anti_sanctions, haioclouds, dns_domains, email_domains, email_accounts. Soft-deleted rows excluded.

ticket_reply is the only state-mutating tool besides ticket_update. Use it as a normal "agent reply" — the same MySQL writes that the TicketController::reply action performs.


3. Adding the MCP server to a client

3.1 OpenChamber (production, my.haio.ir)

The production OpenChamber container is compose-quantify-cross-platform-panel-gcs5w4-openchamber-1 on node60 (94.182.94.3:2280). Its opencode config lives on the host as a named Docker volume; we edit the file in-place and restart the container.

Already done for you (Sept 2026):

"mcp": {
  "haio-ticket": {
    "type": "remote",
    "url": "https://api.haio.ir/mcp-ticket",
    "enabled": true,
    "headers": {
      "Authorization": "Bearer 8d16eb892390eccd7f6f187988ee4ee564e918e5fe01978d"
    }
  }
}

opencode mcp list reports ✓ haio-ticket connected. tools/list returns all 10 tools.

To re-apply after a volume wipe or to a second instance:

# 1. Find the opencode-config volume on node60
ssh root@94.182.172.92 \
  "ssh -i /etc/haio/dokploy-operator-ed25519 -p 2280 root@94.182.94.3 \
    'ls -d /var/lib/docker/volumes/*_openchamber-opencode-config*/_data'"

# 2. Drop this into opencode.json (preserving the rest of the file)
cat >> opencode.json.snippet <<'JSON'
,
"mcp": {
  "haio-ticket": {
    "type": "remote",
    "url": "https://api.haio.ir/mcp-ticket",
    "enabled": true,
    "headers": { "Authorization": "Bearer <TOKEN>" }
  }
}
JSON

# 3. Ship + restart
scp opencode.json.snippet root@94.182.172.92:/tmp/
ssh root@94.182.172.92 \
  "scp -i /etc/haio/dokploy-operator-ed25519 -P 2280 -q /tmp/opencode.json.snippet root@94.182.94.3:/tmp/ && \
   ssh -i /etc/haio/dokploy-operator-ed25519 -p 2280 root@94.182.94.3 \
     'VOL=\$(ls -d /var/lib/docker/volumes/*_openchamber-opencode-config*/_data | head -1); \
      python3 -c \"import json;d=json.load(open(\\\"\$VOL/opencode.json\\\"));d.setdefault(\\\"mcp\\\",{})[\\\"haio-ticket\\\"]=json.load(open(\\\"/tmp/opencode.json.snippet\\\"))[\\\"mcp\\\"][\\\"haio-ticket\\\"];json.dump(d,open(\\\"\$VOL/opencode.json\\\",\\\"w\\\"),indent=2)\" && \
      chown 1000:1000 \$VOL/opencode.json && \
      docker restart \$(docker ps --format \"{{.Names}}\" | grep openchamber-1) && \
      rm /tmp/opencode.json.snippet'"

Schema gotcha: opencode requires type: "remote" and an explicit enabled: true. "streamable-http" is not a valid MCP type and triggers Configuration is invalid … Expected { readonly "type": "local", … } | { readonly "type": "remote", … }. Streamable-HTTP is selected automatically when the server is remote + HTTP.

3.2 Local OpenChamber AppImage (developer machine)

The local AppImage reads ~/.config/opencode/opencode.json (or opencode.jsonc). The same mcp block as above works verbatim. The url must be reachable from your machine — https://api.haio.ir/mcp-ticket is fine as long as your network can reach api.haio.ir:443.

3.3 Raw streamable-HTTP client (any agent)

TOKEN=8d16eb892390eccd7f6f187988ee4ee564e918e5fe01978d
URL=https://api.haio.ir/mcp-ticket

# 1. initialize → grab Mcp-Session-Id from response headers
SID=$(curl -s --noproxy '*' -D - -o /dev/null \
  -X POST "$URL" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"my-agent","version":"1.0"}}}' \
  | awk -F': ' 'tolower($1)=="mcp-session-id"{gsub(/\r/,"",$2);print $2}')

# 2. tools/list (note: stateful — must reuse the SID header)
curl -s --noproxy '*' -X POST "$URL" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'

# 3. tools/call
curl -s --noproxy '*' -X POST "$URL" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"user_search","arguments":{"query":"ali","limit":5}}}'
  • Every POST must be Content-Type: application/json and Accept: application/ json, text/event-stream — FastMCP will reject the request otherwise with 406 Not Acceptable or 415 Unsupported Media Type.

  • Streamable-HTTP is stateful: the very first initialize response contains an Mcp-Session-Id header; echo it back on every subsequent call or the server starts a new session and you lose context.

  • If your shell has HTTPS_PROXY set, add --noproxy '*' to curl or the proxy will re-emit a 421 from the FastMCP transport-security middleware.


4. Local development

The local dev server binds to 127.0.0.1:8801 and talks to a MySQL you bring up yourself (or via SSH tunnel to api.haio.ir).

cd ticket-mcp
python3 -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt

# Option A: SSH tunnel to production DB (read-only is enough for most tools)
ssh -f -N -L 3307:127.0.0.1:3306 root@api.haio.ir

# Option B: local MySQL with a dump of `backend`
#   mysqldump -h api.haio.ir backend tickets ticket_comments users ... | mysql backend_local

export MCP_DB_HOST=127.0.0.1
export MCP_DB_PORT=3307   # 3306 if local
export MCP_DB_USER=mcp
export MCP_DB_PASSWORD=…
export MCP_TOKEN=test123
export MCP_PORT=8801

python server.py
# uvicorn now serving http://127.0.0.1:8801 (POST /mcp, GET /health → 200)

A quick smoke test:

SID=$(curl -s -D - -o /dev/null -X POST http://127.0.0.1:8801/mcp \
  -H "Authorization: Bearer test123" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"smoke","version":"1.0"}}}' \
  | awk -F': ' 'tolower($1)=="mcp-session-id"{gsub(/\r/,"",$2);print $2}')

curl -s -X POST http://127.0.0.1:8801/mcp \
  -H "Authorization: Bearer test123" -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"ticket_statuses","arguments":{}}}' | head

No 401? You got the bearer right. Empty []/list? The tools loaded. You're good.


5. Deploy / re-deploy on api.haio.ir

api.haio.ir is a stock Ubuntu 24.04 host without Docker. The MCP server runs under systemd as ticket-mcp.service against the local MariaDB.

5.1 One-time host setup

ssh root@api.haio.ir
apt-get update && apt-get install -y python3.12-venv
# MariaDB is already running, listens on 127.0.0.1:3306.

5.2 Drop the code

# from your dev machine
scp -r ticket-mcp/ root@api.haio.ir:/opt/
ssh root@api.haio.ir "cd /opt/ticket-mcp && python3.12 -m venv .venv && .venv/bin/pip install -r requirements.txt"

5.3 Create the dedicated MySQL user (least privilege)

ssh root@api.haio.ir
# MySQL root uses auth_socket — log in via sudo or as root and grant the mcp user.
mysql -u root <<'SQL'
CREATE USER 'mcp'@'127.0.0.1' IDENTIFIED BY '…a-strong-secret…';
CREATE USER 'mcp'@'localhost'   IDENTIFIED BY '…a-strong-secret…';
GRANT SELECT ON backend.* TO 'mcp'@'127.0.0.1';
GRANT SELECT, INSERT, UPDATE ON backend.tickets         TO 'mcp'@'127.0.0.1';
GRANT SELECT, INSERT, UPDATE ON backend.ticket_comments TO 'mcp'@'127.0.0.1';
GRANT SELECT ON backend.* TO 'mcp'@'localhost';
GRANT SELECT, INSERT, UPDATE ON backend.tickets         TO 'mcp'@'localhost';
GRANT SELECT, INSERT, UPDATE ON backend.ticket_comments TO 'mcp'@'localhost';
FLUSH PRIVILEGES;
SQL

5.4 systemd unit

/etc/systemd/system/ticket-mcp.service:

[Unit]
Description=Haio Ticket MCP (streamable-HTTP)
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
WorkingDirectory=/opt/ticket-mcp
ExecStart=/opt/ticket-mcp/.venv/bin/python /opt/ticket-mcp/server.py
Restart=always
RestartSec=3
Environment=MCP_HOST=0.0.0.0
Environment=MCP_PORT=8801
Environment=MCP_DB_HOST=127.0.0.1
Environment=MCP_DB_PORT=3306
Environment=MCP_DB_USER=mcp
Environment=MCP_DB_PASSWORD=…a-strong-secret…
Environment=MCP_DB_NAME=backend
Environment=MCP_AGENT_USER_ID=33
Environment=MCP_TOKEN=…long-random-token…

[Install]
WantedBy=multi-user.target
ssh root@api.haio.ir
systemctl daemon-reload
systemctl enable --now ticket-mcp
systemctl status ticket-mcp --no-pager
curl -s http://127.0.0.1:8801/health
# {"status":"ok"}

5.5 nginx reverse proxy

Port 8801 is not exposed externally (datacenter firewall). Add a single line inside the api.haio.ir 443 server block of /etc/nginx/conf.d/haio.conf:

location = /mcp-ticket {
    proxy_pass http://127.0.0.1:8801/mcp;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header Connection "";
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_read_timeout 3600s;
    proxy_buffering off;
}

Reload:

ssh root@api.haio.ir "nginx -t && systemctl reload nginx"

Why an exact-match (=) location: it prevents any path-suffix from being appended, so the upstream always sees POST /mcp regardless of how the client spells the URL. If you ever move the MCP behind a prefix (e.g. /mcp/ticket), remember to also accept /mcp/ticket/mcp for clients that append the standard suffix:

location ^~ /mcp/ticket/ {
    proxy_pass http://127.0.0.1:8801/mcp/;
    # …same headers as above
}

6. How to create / rotate the Bearer token

The token is a single shared secret stored in one place: the MCP_TOKEN env var inside the systemd unit. Rotating it means:

  1. Generate a new value.

  2. Edit the systemd unit to set the new value.

  3. systemctl restart ticket-mcp so the server picks it up.

  4. Update the mcp.haio-ticket.headers.Authorization value in every OpenChamber / opencode config that references the server.

  5. Restart the OpenChamber container so it reconnects.

6.1 Generate a strong token

# 96 hex chars (48 random bytes) — same recipe as the production one.
python3 -c "import secrets;print(secrets.token_hex(24))"
# 8d16eb892390eccd7f6f187988ee4ee564e918e5fe01978d

Any opaque random string works; the server does a constant-time auth != expected compare so length doesn't matter much, but 32+ bytes is plenty.

6.2 Apply on the server

NEW=$(python3 -c "import secrets;print(secrets.token_hex(24))")
ssh root@api.haio.ir bash - <<EOF
sed -i "s|^Environment=MCP_TOKEN=.*|Environment=MCP_TOKEN=$NEW|" \
  /etc/systemd/system/ticket-mcp.service
systemctl daemon-reload
systemctl restart ticket-mcp
sleep 2
systemctl is-active ticket-mcp
EOF
echo "New token: $NEW"
# store it in your secret manager NOW — the shell variable is gone

6.3 Update every consumer

For the production OpenChamber instance, edit the same volume file:

NEW=…new-token…
ssh root@94.182.172.92 <<EOF
ssh -i /etc/haio/dokploy-operator-ed25519 -p 2280 root@94.182.94.3 bash <<INNER
VOL=\$(ls -d /var/lib/docker/volumes/*_openchamber-opencode-config*/_data | head -1)
python3 -c "
import json
p='$VOL/opencode.json'
d=json.load(open(p))
d['mcp']['haio-ticket']['headers']['Authorization']='Bearer $NEW'
json.dump(d,open(p,'w'),indent=2)
print('updated')
"
chown 1000:1000 \$VOL/opencode.json
docker restart \$(docker ps --format '{{.Names}}' | grep openchamber-1)
INNER
EOF

For any other consumer (a developer AppImage, a CI agent, a custom script), update its opencode.json (or its hard-coded header) the same way and restart it.

6.4 Per-agent tokens (optional)

If you want to give different agents their own credentials (so you can revoke just one without breaking the others), the code today only supports a single MCP_TOKEN. To split, change the AuthMiddleware in server.py:

TOKENS = {
    secrets.compare_digest:  # placeholder — use a dict of name → token
    # e.g. {"agent-router": "…", "openchamber-1": "…"}
}
# in __call__:
auth = headers.get(b"authorization", b"").decode()
if not any(secrets.compare_digest(auth, f"Bearer {t}") for t in TOKENS.values()):
    return JSONResponse({"error": "unauthorized"}, status_code=401)

Pick the bearer by prefixing the token with a label (agent-router:abcd…) if you want a self-identifying scheme, or just keep N opaque tokens and a side-table of which agent owns which.


7. Operational notes & gotchas

  • FastMCP 1.29+ enables DNS-rebinding protection by default. The middleware checks Host/Origin against an allow-list. Because the public hostname is api.haio.ir and nginx terminates TLS, the check rejects every request with 421 Invalid Host header. transport_security=TransportSecuritySettings (enable_dns_rebinding_protection=False) is required. The Bearer token is the security boundary.

  • Streamable-HTTP is stateful. Reuse Mcp-Session-Id from the initialize response on every subsequent call, or the server will treat each request as a new session and you'll lose tool-call context.

  • Every POST needs Content-Type: application/json AND Accept: application/json, text/event-stream. Both. FastMCP returns 415 on missing Content-Type and 406 on missing/wrong Accept.

  • MySQL root only via auth_socket. You cannot mysql -u root -p from the MCP host; use sudo mysql or create a dedicated user (we created mcp).

  • Run systemctl edit after editing the unit. Environment= lines are read at start, so a bare systemctl restart is enough, but a daemon-reload is required if you changed the unit file itself.

  • Don't pkill -f server.py. It kills the bash that's running pkill, because the pattern matches the shell command line. Use systemctl restart ticket-mcp instead.

  • opencode.json schema is strict. Use type: "remote", NOT streamable-http, and add enabled: true. See §3.1.

  • Long replies time out nginx by default. proxy_read_timeout 3600s; is required because the agent may keep an SSE channel open while the LLM thinks.


8. Files in this directory

ticket-mcp/
├── server.py         # the entire MCP server (single file, FastMCP + pymysql)
├── requirements.txt  # mcp>=1.9.0,<2  pymysql  uvicorn  starlette
├── Dockerfile        # alternative container build (NOT used in prod — api.haio.ir has no Docker)
├── .venv/            # dev virtualenv
└── README.md         # this file

To rebuild the systemd deployment after editing server.py:

scp server.py root@api.haio.ir:/opt/ticket-mcp/server.py
ssh root@api.haio.ir systemctl restart ticket-mcp
ssh root@api.haio.ir journalctl -u ticket-mcp -n 20 --no-pager

9. Current live values (Sept 2026)

Setting

Value

Public URL

https://api.haio.ir/mcp-ticket

Token

8d16eb892390eccd7f6f187988ee4ee564e918e5fe01978d

Server host

api.haio.ir (94.182.172.77), systemd ticket-mcp.service

DB

MySQL backend @ 127.0.0.1:3306, user mcp (limited grants)

OpenChamber instance

compose-quantify-cross-platform-panel-gcs5w4-openchamber-1 on node60

OpenChamber MCP name

haio-ticket (registered in that container's opencode.json)

Status

opencode mcp list reports ✓ haio-ticket connected; all 10 tools load

Rotate at any time using §6.

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/haioco/haio-ticket-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server