Skip to main content
Glama
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.