haio-ticket
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 |
|
Transport | MCP streamable-HTTP (POST JSON; |
Auth header |
|
Server host |
|
Database | MySQL |
OpenChamber | Already registered as |
Source code |
|
Current production token (rotate via §6 if you need a new one):
8d16eb892390eccd7f6f187988ee4ee564e918e5fe01978d1. 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 dataKey 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
replywrites go.Dedicated DB user
mcpwithSELECTonbackend.*andSELECT,INSERT, UPDATEontickets+ticket_commentsonly. The server cannot accidentally drop a column or rewriteusers.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=Falsein FastMCP. The SDK enforcesHost/Originchecks by default in 1.29+; with the public DNS nameapi.haio.irand a TLS-terminating nginx, the default check rejects every request with421 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 |
| — | Static map of status IDs → Persian labels (1=جدید … 7=بسته شده). |
| — | Active categories from |
| — | All labels from |
|
| Paginated list, JOIN users, includes |
|
| Ticket header + full comment thread (oldest → newest) + user summary. |
|
| Insert as کارشناس ( |
|
| Change one or more of the three fields. |
Users
Tool | Args | Purpose |
|
| LIKE on |
|
| Profile row + every wallet balance ( |
|
| Dict of product lists: |
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 explicitenabled: true."streamable-http"is not a valid MCP type and triggersConfiguration 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/jsonandAccept: application/ json, text/event-stream— FastMCP will reject the request otherwise with406 Not Acceptableor415 Unsupported Media Type.Streamable-HTTP is stateful: the very first
initializeresponse contains anMcp-Session-Idheader; echo it back on every subsequent call or the server starts a new session and you lose context.If your shell has
HTTPS_PROXYset, 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":{}}}' | headNo 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;
SQL5.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.targetssh 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:
Generate a new value.
Edit the systemd unit to set the new value.
systemctl restart ticket-mcpso the server picks it up.Update the
mcp.haio-ticket.headers.Authorizationvalue in every OpenChamber / opencode config that references the server.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))"
# 8d16eb892390eccd7f6f187988ee4ee564e918e5fe01978dAny 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 gone6.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
EOFFor 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/Originagainst an allow-list. Because the public hostname isapi.haio.irand nginx terminates TLS, the check rejects every request with421 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-Idfrom theinitializeresponse 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/jsonANDAccept: application/json, text/event-stream. Both. FastMCP returns415on missing Content-Type and406on missing/wrong Accept.MySQL
rootonly via auth_socket. You cannotmysql -u root -pfrom the MCP host; usesudo mysqlor create a dedicated user (we createdmcp).Run
systemctl editafter editing the unit.Environment=lines are read at start, so a baresystemctl restartis enough, but adaemon-reloadis 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. Usesystemctl restart ticket-mcpinstead.opencode.jsonschema is strict. Usetype: "remote", NOTstreamable-http, and addenabled: 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 fileTo 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-pager9. Current live values (Sept 2026)
Setting | Value |
Public URL |
|
Token |
|
Server host | api.haio.ir (94.182.172.77), systemd |
DB | MySQL |
OpenChamber instance |
|
OpenChamber MCP name |
|
Status |
|
Rotate at any time using §6.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
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