haio-ticket
by haioco
README.md
# 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):**
```json
"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:**
```bash
# 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)
```bash
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).
```bash
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:
```bash
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
```bash
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
```bash
# 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)
```bash
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`:
```ini
[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
```
```bash
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`:
```nginx
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:
```bash
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:
```nginx
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
```bash
# 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
```bash
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:
```bash
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`:
```python
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`:
```bash
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.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues