woow_emqx_mcp_server
The Woow EMQX MCP Server provides AI assistants with 39 curated tools to securely monitor, manage, and operate an EMQX MQTT broker. It includes a token-authenticated interface, a web admin GUI, and a reverse proxy in a single container.
Cluster & Monitoring: Check cluster health, node details, live stats, metrics (current/historical), alarms, and Prometheus data.
Client Management: List, inspect, and manage clients; kick (disconnect), subscribe, or unsubscribe clients.
Topics & Subscriptions: List routed topics and subscriptions; find subscribers for a specific topic.
Messaging: Publish single or bulk messages (up to 50); list, read, and delete retained messages.
Access Control: Manage authenticators, MQTT users, authorization sources, ACL rules, and bans (ban/unban).
Diagnostics: Create, read, and delete packet traces; list listeners and traces.
Data Integration: List, toggle, and test rule-engine rules; view rule metrics; inspect connectors and actions.
Safety & Control: 13 destructive tools are explicitly marked; tools can be restricted by category, operation, or read-only mode.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@woow_emqx_mcp_server list all connected clients and their subscriptions"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
Overview
Woow EMQX MCP Server turns an EMQX MQTT broker into something an AI assistant can operate safely. It exposes the EMQX REST API v5 as 39 MCP tools, puts a token-authenticated reverse proxy in front of them, and ships a React admin console for configuring the lot — all in a single container.
Ask Claude "why did my sensor stop reporting?" and it can list the client, check whether the session is still alive, look at what the client is subscribed to, confirm the topic has a route, read the retained payload, and tell you the device never reconnected after its last will fired — without you opening a dashboard.
This bundle is the EMQX sibling of
woow_n8n_mcp_server and shares its
mcp_admin_core foundation, so the operational model, GUI and proxy semantics are identical
across the WOOWTECH MCP fleet.
Why This Bundle?
Challenge | Solution |
The EMQX REST API is large and easy to misuse from an LLM | 39 curated tools with descriptions written for models, not humans |
An open MCP endpoint would let anything drive your broker | Token-authenticated reverse proxy; the MCP server binds to |
"Give the AI everything" is not an acceptable security posture | Three-level gating — category, tool, and per-operation — plus a read-only master switch |
Destructive operations look identical to reads over MCP | Every tool carries MCP annotations; |
Tool configuration means editing env vars and restarting | Visual toggles in the GUI; changes are written into the subprocess env and applied on restart |
Debugging a broken MCP server means | Live SSE log streaming with search, in the browser |
Claude's connector refuses no-auth remote servers | Two ready-made Cloudflare Workers: an OAuth 2.1 gateway and a clean-404 public endpoint |
Before vs. After
Aspect | Raw EMQX REST API | With This Bundle |
AI access | Hand-rolled HTTP calls per assistant | One MCP URL, 39 typed tools |
Auth | API key/secret pasted into every client | Key stored once, proxy token handed out instead |
Blast radius | Full API surface | Whatever the switches allow |
Errors | Raw 4xx bodies | Messages that tell the model what to do next |
Observability | Broker logs only | Dashboard health + streaming MCP logs |
Deployment | N/A | Single container: Docker, Podman, or Kubernetes |
Related MCP server: Kafka MCP Server
Features
Dashboard
Aggregated health for the whole stack in one view — the MCP server subprocess (with PID), the EMQX broker connection (with the REST URL it is using), and the built-in reverse proxy. Below that, the MCP Admin version, the EMQX cluster node name, and the live count of connected MQTT clients.
Tool Manager
All 39 tools grouped into 7 categories, each with a toggle. The header shows the enabled count
(39 of 39 tools enabled), and a search box filters by name, description or category. Dangerous
tools are marked. Toggling writes the disabled set into the MCP server's environment and restarts
the subprocess, so the change reaches the live MCP surface — a disabled tool disappears from
tools/list entirely rather than failing at call time.
Connection Configuration
Point the bundle at your broker: the EMQX Dashboard URL (base only — /api/v5 is appended for
you), API Key and API Secret. The secret is write-only; leaving it blank keeps the stored value,
because EMQX shows a secret exactly once, at creation. Test Connection performs a real call
and reports the broker edition, version and node count.
Permission Editor
A JSON policy editor for allowed_tools / denied_tools, for when a per-tool toggle is too
coarse or you want the policy under version control. Denying a tool here removes it from the MCP
surface the same way the toggle does.
Token Manager
The MCP proxy token: masked display, one-click rotation, and the proxy URL template. Rotating generates a new token, applies it, and restarts the proxy in one step.
Log Viewer
Live SSE stream of the MCP server subprocess with an in-memory ring buffer, text/regex search, pause, auto-scroll and clear. Every line is structured — timestamp, level, message, source — so the viewer can colour and filter them.
Settings
Full process control for the MCP subprocess (command, args, port, environment, restart), the proxy (timeout up to 24 h, optional upstream bearer), and the admin password.
Edge Deployment (Cloudflare)
Two Workers in cloudflare/ solve the two ways a remote MCP endpoint typically fails
to connect from Claude:
mcp-oauth-gateway.js— a full OAuth 2.1 authorization server at the edge: RFC 9728 protected-resource metadata, RFC 8414 AS metadata, RFC 7591 dynamic client registration, PKCE S256 authorize/token, and refresh-token rotation. The upstream proxy token never leaves the edge.mcp-direct.js— a dedicated hostname that serves the proxy and answers every other path, including/.well-known/*, with a clean JSON 404. That 404 is what lets a connector conclude "no sign-in service here" and fall back to an anonymous connection.
Architecture
┌─────────────────────────────────────────────────────────────────────┐
│ Woow EMQX MCP Admin Bundle │
│ (Single Container) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ React SPA (Vite + Tailwind CSS) │ │
│ │ │ │
│ │ Dashboard │ Tools │ Connection │ Tokens │ Logs │ Permissions │ │
│ └──────────────────────────┬────────────────────────────────────┘ │
│ │ HTTP │
│ ┌──────────────────────────▼────────────────────────────────────┐ │
│ │ FastAPI Backend (:8080) │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌───────────┐ ┌──────────────┐ │ │
│ │ │ Auth │ │ Config │ │ Process │ │ MCP Proxy │ │ │
│ │ │Middleware│ │ Store │ │ Manager │ │ /private_* │ │ │
│ │ └──────────┘ └──────────┘ └───────────┘ └──────┬───────┘ │ │
│ └───────────────────────────────────────────────────│──────────┘ │
│ │ │
│ ┌───────────────────────────────────────────────────▼──────────┐ │
│ │ emqx_mcp_server (FastMCP, 127.0.0.1:3000) │ │
│ │ │ │
│ │ ToolGate │ 39 tools / 7 categories │ Streamable HTTP │ │
│ └──────────────────────────┬────────────────────────────────────┘ │
│ │ httpx (pooled, Basic auth) │
├─────────────────────────────┼──────────────────────────────────────┤
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ EMQX REST API v5 (:18083/api/v5) │ │
│ │ Clients │ Topics │ Retained │ AuthN/AuthZ │ Rules │ Traces │ │
│ └───────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘Request path, end to end
sequenceDiagram
autonumber
participant AI as AI assistant
participant CF as Cloudflare Worker
participant PX as MCP proxy<br/>/private_{token}/mcp
participant MCP as emqx_mcp_server<br/>127.0.0.1:3000
participant EMQX as EMQX REST API v5
AI->>CF: POST /mcp (initialize)
Note over CF: OAuth bearer checked,<br/>private path injected
CF->>PX: POST /private_{token}/mcp/
PX->>MCP: forward, strip hop-by-hop headers
MCP-->>PX: capabilities + mcp-session-id
PX-->>CF: 200 text/event-stream
CF-->>AI: session established
AI->>CF: tools/call emqx_list_clients
CF->>PX: forward
PX->>MCP: forward
Note over MCP: ToolGate check:<br/>category / tool / operation
MCP->>EMQX: GET /api/v5/clients?limit=50
EMQX-->>MCP: 200 JSON
Note over MCP: shape into a typed result
MCP-->>AI: structured contentThree-level tool gating
Every call passes the same gate. A tool that fails any level is never registered, so it does not
appear in tools/list at all — the model cannot even attempt it.
flowchart TD
A[Tool registration] --> B{Explicitly disabled?}
B -- yes --> X[Not registered]
B -- no --> C{Read-only mode<br/>and tool is dangerous?}
C -- yes --> X
C -- no --> D{Category disabled?}
D -- yes --> X
D -- no --> E{Any operation<br/>still allowed?}
E -- no --> X
E -- yes --> F[Registered on the MCP surface]
F --> G[Call arrives]
G --> H{Operation allowed<br/>for this call?}
H -- no --> Y[ToolError: operation switched off]
H -- yes --> Z[EMQX REST call]
style X fill:#FDECEC,stroke:#B4231F,color:#B4231F
style Y fill:#FDECEC,stroke:#B4231F,color:#B4231F
style F fill:#E8F0FF,stroke:#6183FC,color:#25408F
style Z fill:#E6F7F0,stroke:#00B173,color:#046B48Module dependency graph
graph TD
subgraph repo["Woow_emqx_mcp_server"]
SRV["emqx_mcp_server<br/><i>FastMCP server, 39 tools</i>"]
ADM["emqx_mcp_admin<br/><i>EMQX-specific admin routers</i>"]
CORE["mcp_admin_core<br/><i>shared: app, proxy, process, auth, config</i>"]
FE["frontend<br/><i>React 19 SPA</i>"]
OVR["frontend-overrides<br/><i>EMQX ConnectionConfig page</i>"]
CFW["cloudflare<br/><i>OAuth gateway + public endpoint</i>"]
end
REG["registry.py<br/><i>single source of truth</i>"]
SRV --> REG
ADM --> REG
ADM --> CORE
FE --> CORE
OVR --> FE
CORE --> SRV
CFW -.proxies.-> CORE
style REG fill:#E8F0FF,stroke:#6183FC,color:#25408F
style CORE fill:#F5F6F8,stroke:#9AA0AAregistry.py is deliberately shared: the GUI's tool list and the MCP server's registration loop
read the same 39 ToolSpec entries, so the console can never show a tool the server does not
serve. A test (tests/test_mcp_surface.py) asserts that parity.
Deployment topology
graph LR
subgraph internet["Public internet"]
CLIENT["Claude / Cursor /<br/>any MCP client"]
end
subgraph cf["Cloudflare"]
W1["Worker: OAuth 2.1 gateway"]
W2["Worker: clean-404 endpoint"]
TUN["Tunnel"]
end
subgraph k8s["k3s cluster"]
POD["emqx-mcp-admin pod<br/>:8080 admin + proxy<br/>:3000 MCP (localhost)"]
end
subgraph lan["LAN"]
BROKER["EMQX 5.8.8<br/>:1883 :8883 :8083 :8084<br/>:18083 REST"]
end
CLIENT -->|HTTPS| W1
CLIENT -->|HTTPS| W2
W1 --> TUN
W2 --> TUN
TUN --> POD
POD -->|REST v5| BROKER
style W1 fill:#E8F0FF,stroke:#6183FC,color:#25408F
style W2 fill:#E8F0FF,stroke:#6183FC,color:#25408F
style BROKER fill:#E6F7F0,stroke:#00B173,color:#046B48More detail, including the failure modes each layer absorbs, is in docs/architecture.md.
The 39 Tools
Seven categories. 13 tools are destructive and carry destructiveHint in their MCP
annotations; the rest are marked readOnlyHint. Tools with an Operations column can be
restricted further — you can allow read on emqx_manage_authn_users while denying create and
delete.
Cluster & Monitoring (7)
Tool | Description |
| Cluster nodes with version, uptime, CPU and memory |
| Full detail for one node, including load and connection counts |
| Live counters: connections, sessions, subscriptions, topics |
| Current throughput gauges used by the dashboard charts |
| Time-series metrics over a recent window |
| Active and historical broker alarms |
| Raw Prometheus exposition text for scraping or diffing |
Client Management (6)
Tool | Description | Destructive | Operations |
| MQTT clients known to the broker, with filters | read | |
| Full session detail for a single client | read | |
| Topics a specific client is subscribed to | read | |
| Forcibly disconnect a client and clear its session | ⚠ | delete |
| Subscribe a client to a topic on its behalf | ⚠ | create |
| Remove a subscription from a client on its behalf | ⚠ | delete |
Topics & Subscriptions (2)
Tool | Description |
| Routed topics currently present in the cluster |
| All subscriptions cluster-wide, filterable by topic or client |
emqx_list_subscriptions accepts match_topic: give it a concrete topic and it tells you exactly
which subscribers would receive a message published there. That single parameter answers most
"I published and nothing happened" questions.
Messaging (5)
Tool | Description | Destructive | Operations |
| Publish one MQTT message through the broker | ⚠ | create |
| Publish a batch of messages in one call (max 50) | ⚠ | create |
| Retained messages the broker is holding | read | |
| The retained message stored for one topic | read | |
| Delete the retained message for one topic | ⚠ | delete |
Three real-broker behaviours are handled here rather than left to the model: EMQX answers 202
with reason_code 16 when a publish had no subscribers (surfaced as
delivered_to_subscribers: false), retained payloads come back base64-encoded (decoded
transparently), and EMQX rejects a slash inside the retained-message path segment, so hierarchical
topics fall back to a listing scan for reads and an empty retained publish for deletes.
Access Control (8)
Tool | Description | Destructive | Operations |
| Authenticator chain and each authenticator's status | read | |
| List, create and delete MQTT users in the built-in database | ⚠ | create, delete, read |
| Authorization sources and their order in the chain | read | |
| Global authorization behaviour: no-match, deny action, cache | read | |
| Read and write built-in-database ACL rules | ⚠ | create, delete, read |
| Current ban list entries | read | |
| Ban a client id, username or IP from connecting | ⚠ | create |
| Lift a ban | ⚠ | delete |
Diagnostics (5)
Tool | Description | Destructive | Operations |
| Packet traces currently defined on the broker | read | |
| Start a packet trace for a client, topic or IP | ⚠ | create |
| Read the captured log for a trace | read | |
| Delete a trace; unflushed events are lost | ⚠ | delete |
| Listeners with bind addresses and running state | read |
Data Integration (6)
Tool | Description | Destructive | Operations |
| Rule-engine rules with their SQL and enabled state | read | |
| Match, pass and failure counters for one rule | read | |
| Enable or disable a rule | ⚠ | update |
| Dry-run rule SQL against a sample event, saving nothing | read | |
| Data-integration connectors and their health | read | |
| Outbound actions (bridges) and their health | read |
Screenshots
All screenshots are from a live deployment: EMQX 5.8.8 Opensource, node
emqx@woowtechshowha.local, reached over the LAN at 192.168.2.189:18083, with the bundle running
in k3s behind a Cloudflare Tunnel.
Login
JWT authentication with the admin password. The token is stored in an httpOnly cookie; every
/api/* route except the login endpoint and /healthz requires it.
Dashboard
Three status cards across the top — MCP SERVER (Online, with the subprocess PID), EMQX
BROKER (Connected, showing the REST base URL actually in use) and MCP PROXY (Active). The
Details row underneath carries the MCP Admin version, the EMQX cluster node name
(emqx@woowtechshowha.local) and the count of connected MQTT clients, read live from
/api/v5/stats.
Tool Manager
39 of 39 tools enabled, grouped by category with a per-tool toggle and a search box. Access
Control is expanded here, showing all eight of its tools with the one-line descriptions the model
also sees. Switching a tool off rewrites the subprocess environment and restarts it, so the tool
leaves tools/list — it is not merely blocked at call time.
EMQX Connection
The broker's REST endpoint and credentials. Only the base URL is entered — /api/v5 is appended
automatically, which is the single most common configuration mistake this removes. The API Secret
field is masked and write-only: leave it blank and the stored secret is kept, because EMQX only
ever shows a secret once, at creation time.
Token Manager
The MCP proxy token, masked to first and last four characters, with the proxy URL template underneath and one-click rotation. Rotating generates, applies and restarts in a single action.
Permission Editor
The same gating expressed as a JSON policy — allowed_tools and denied_tools — for when you
want the policy reviewable in a diff rather than clicked into a form. Format, Reset and Save;
denying a tool here removes it from the MCP surface exactly as the toggle does.
Log Viewer
Live SSE stream from the MCP subprocess, connected and holding 200 lines. Each line carries its
own level parsed from the child process — here, a run of POST /mcp/ requests from a connected
assistant, with 202 Accepted for notifications and 200 OK for calls. Pause, auto-scroll, clear
and a filter box sit above a 5000-line ring buffer.
Settings
Process control for the MCP subprocess: running state, PID, restart count, and the exact command
line — python3 -m emqx_mcp_server.server --transport http --host 127.0.0.1 --port 3000. Binding
to 127.0.0.1 is deliberate: the MCP server is reachable only through the authenticated proxy,
never directly. Proxy timeout and admin password live further down the page.
Installation
Prerequisites
EMQX 5.8+ with the Dashboard REST API reachable (default port
18083)An EMQX API Key / Secret pair (EMQX Dashboard → System → API Key)
Docker, Podman or Kubernetes
For development: Python 3.12+ and Node.js 20
Option 1: Docker / Podman
git clone https://github.com/WOOWTECH/Woow_emqx_mcp_server.git
cd Woow_emqx_mcp_server
docker build -t emqx-mcp-admin .
docker run -d \
--name emqx-mcp-admin \
-p 8080:8080 \
-v ./data:/data \
emqx-mcp-adminOpen http://localhost:8080 and log in. Configure the broker on the Connection page.
Option 2: Docker Compose
docker compose up -dOption 3: Kubernetes (Helm — recommended)
helm install emqx-mcp charts/emqx-mcp -n emqx-mcp --create-namespace
kubectl -n emqx-mcp get podscharts/emqx-mcp renders exactly the objects k8s-deploy.yaml
declares, and adds what the manifest never had: the three Secrets are documented with
placeholders in charts/emqx-mcp/examples/secrets.example.yaml and can be created by the chart
with --set secrets.create=true, helm uninstall keeps them, and helm test runs a read-only
smoke check of the GUI, the admin API and the MCP endpoint.
Option 3b: Kubernetes (raw manifest)
kubectl apply -f k8s-deploy.yaml
kubectl -n emqx-mcp get podsThe manifest creates the namespace, a Deployment with readiness and liveness probes on
/healthz, and a ClusterIP Service on :8080. It does not create any Secret: it only
references emqx-mcp-config, emqx-mcp-jwt and ghcr-pull, so create those first (see
charts/emqx-mcp/examples/secrets.example.yaml) or the pod hangs in Init. An init container
seeds /data/config.json from emqx-mcp-config on every start, so the Secret is the source
of truth.
Option 4: Development
pip install -e .
pip install -e ".[dev]"
cd frontend && npm install && npm run build && cd ..
export EMQX_MCP_BASE_URL=http://your-broker:18083
export EMQX_MCP_API_KEY=...
export EMQX_MCP_API_SECRET=...
# MCP server alone, over stdio
python -m emqx_mcp_server.server
# or the full admin bundle
uvicorn emqx_mcp_admin.main:app --port 8080Configuration
All settings live in /data/config.json:
{
"admin_password": "change-me",
"mcp_auth_token": "32-char-url-safe-token",
"connection": {
"emqx_mcp_base_url": "http://192.168.2.189:18083",
"emqx_mcp_api_key": "your-api-key",
"emqx_mcp_api_secret": "your-api-secret"
},
"tools": {
"disabled_tools": [],
"disabled_categories": [],
"disabled_operations": {},
"readonly": false,
"permissions": { "allowed_tools": ["*"], "denied_tools": [] }
},
"mcp_server": {
"command": "python3",
"args": ["-m", "emqx_mcp_server.server", "--transport", "http",
"--host", "127.0.0.1", "--port", "3000"],
"port": 3000,
"env": {
"EMQX_MCP_DISABLED_CATEGORIES": "",
"EMQX_MCP_DISABLED_TOOLS": "",
"EMQX_MCP_DISABLED_OPERATIONS": "{}",
"EMQX_MCP_READONLY": "false"
}
},
"proxy": { "timeout": 86400, "bearer_token": "" },
"token_history": []
}Environment variables
Variable | Default | Description |
|
| Path to the configuration file |
| random | JWT signing secret — set this in production, or sessions break on restart |
|
| Admin session lifetime |
| — | EMQX dashboard base URL (no |
| — | EMQX API key |
| — | EMQX API secret |
|
| Master switch: drop every destructive tool |
| empty | Comma-separated tool names |
| empty | Comma-separated category names |
|
| JSON map of tool → disabled operations |
|
| Default page size for list tools |
|
| Hard cap on page size |
Connecting AI Assistants
The proxy exposes the MCP server at /private_{token}/mcp/. The trailing slash matters — the
upstream redirects the slashless form, and a cross-host redirect drops the request.
Direct, on your own network
http://your-server:8080/private_{token}/mcp/Claude Code / Cursor / any URL-based client
claude mcp add --transport http woow-emqx \
https://your-host/private_{token}/mcp/Claude's custom-connector UI
The connector runs OAuth discovery before it connects, so a bare proxy URL fails with "Couldn't
register with … sign-in service". Deploy one of the Workers in cloudflare/:
Worker | URL shape | Auth | Use when |
|
| path token only | You want the same shape as the rest of the WOOWTECH MCP fleet |
|
| OAuth 2.1 + password | The endpoint is public and you want real authentication |
Both keep the upstream token at the edge and normalise the trailing slash. See docs/architecture.md for why the clean 404 matters.
API Reference
Authentication
Method | Endpoint | Description |
|
| Authenticate with the admin password, returns a JWT |
Dashboard
Method | Endpoint | Description |
|
| Aggregated health: MCP server, broker, proxy, version, node, connections |
Connection
Method | Endpoint | Description |
|
| Current connection settings, secret masked |
|
| Update base URL, API key, API secret |
|
| Live connectivity test; returns edition, version, node count |
|
| Replace the allow/deny tool policy |
Tools
Method | Endpoint | Description |
|
| All 39 tools with category, description and enabled state |
|
| Apply a tool enable/disable set and restart the subprocess |
|
| Update per-tool disabled operations |
Tokens
Method | Endpoint | Description |
|
| Current token, masked, plus rotation history |
|
| Generate, apply and restart in one step |
|
| Set a specific token value |
Logs
Method | Endpoint | Description |
|
| SSE stream, replays the recent buffer then follows |
|
| Text or regex search across the ring buffer |
Settings
Method | Endpoint | Description |
|
| Full config with secrets masked |
|
| Replace one config section |
|
| Subprocess status: running, pid, restart count |
|
| Restart the MCP subprocess |
System
Method | Endpoint | Description |
|
| Kubernetes-compatible health check, unauthenticated |
|
| The MCP endpoint itself |
Security
Authentication layers
flowchart TD
subgraph L1["Layer 1 - Admin GUI"]
A1["POST /api/auth/login"] --> A2["JWT HS256, httpOnly cookie"]
A2 --> A3["every /api/* route<br/>except login and /healthz"]
end
subgraph L2["Layer 2 - MCP proxy"]
B1["/private_{token}/mcp/"] --> B2{"token matches<br/>config store?"}
B2 -- no --> B3["403 Forbidden"]
B2 -- yes --> B4["forward to 127.0.0.1:3000"]
end
subgraph L3["Layer 3 - Tool gate"]
C1["category / tool / operation"] --> C2["read-only master switch"]
end
subgraph L4["Layer 4 - EMQX"]
D1["API key + secret, HTTP Basic,<br/>never sent to the client"]
end
L1 --> L2 --> L3 --> L4
style B3 fill:#FDECEC,stroke:#B4231F,color:#B4231FSecurity properties
The MCP server is never directly exposed. It binds to
127.0.0.1:3000; the only route in is the token-authenticated proxy.EMQX credentials never reach the client. They live in the config store and are used server-side for HTTP Basic auth. The API secret is write-only through the API.
Constant-time password comparison via
secrets.compare_digest.Injected dependencies are stripped from the tool schema, so a model can neither see nor override the HTTP client.
Error messages are actionable, not leaky.
mask_error_detailsis on; unexpected exceptions become a generic message while deliberateToolErrors reach the model verbatim.Every response is
Cache-Control: no-store, so a CDN in front cannot serve one operator's view to another.Destructive tools are declared as such in MCP annotations, so a well-behaved client can ask for confirmation before calling them.
Hardening checklist
Change
admin_passwordon first login.Set
JWT_SECRETexplicitly.Rotate
mcp_auth_tokenafter handing it to anyone, and use the OAuth gateway for public exposure rather than the path token.Give the bundle an EMQX API key scoped to what it actually needs.
Turn on
readonlyfor assistants that only need to observe.Put the MCP endpoint on its own hostname — never share one with the admin GUI.
Testing
pip install -e ".[dev]"
pytest -vTest file | Covers | Tests |
| Three-level gating and the read-only switch | 7 |
| Registration, annotations, registry ↔ server parity | 6 |
|
| 4 |
| Config keys reach the subprocess as | 3 |
| Slash-in-topic fallbacks, base64 payloads | 3 |
| Log capture, classification and SSE framing | 1 |
| The pooled client survives more than one tool call | 2 |
Total | 26 passing, 11 live-broker tests skipped without credentials |
The live-broker tests run against a real EMQX when EMQX_MCP_BASE_URL, EMQX_MCP_API_KEY and
EMQX_MCP_API_SECRET are set.
Verified against a real deployment
Suite | Result |
GUI + MCP audit over public HTTPS (endpoints, shapes, EMQX semantics, behaviour, security) | 64 / 64 |
Cloudflare OAuth gateway (discovery, DCR, PKCE, tokens, security, MCP) | 29 / 29 |
Public endpoint shape (clean 404s, slash forms, token rejection, tool calls) | 15 / 15 |
Tech Stack
Component | Technology | Version |
MCP server | FastMCP | 3.4+ |
Backend | FastAPI + Uvicorn | 0.115+, Python 3.12 |
Frontend | React + Tailwind CSS + Vite | React 19, Tailwind 3.4, Vite 6 |
HTTP client | httpx | pooled |
Auth | PyJWT | HS256 |
Broker | EMQX | 5.8+ (tested on 5.8.8 Opensource) |
Transport | MCP Streamable HTTP | 2025-06-18 |
Edge | Cloudflare Workers | ES modules |
Container | Docker / Podman | multi-stage |
Orchestration | Kubernetes / k3s | v1.31+ |
Project Structure
Woow_emqx_mcp_server/
├── emqx_mcp_server/ # The MCP server
│ ├── server.py # build_server(): FastMCP app factory
│ ├── registry.py # 39 ToolSpecs - shared with the GUI
│ ├── gating.py # ToolGate: category / tool / operation
│ ├── deps.py # EmqxHttp: borrowed, non-closeable client handle
│ ├── errors.py # EMQX failures -> actionable ToolErrors
│ ├── lifespan.py # pooled httpx client
│ ├── models.py # typed results
│ ├── settings.py # EMQX_MCP_* settings
│ └── tools/ # cluster, clients, topics, messaging,
│ # security, diagnostics, integration
├── emqx_mcp_admin/ # EMQX-specific admin layer
│ ├── main.py # create_app(extra_routers=[...])
│ └── routers/ # config, tools, health, logs, tokens
├── mcp_admin_core/ # Shared core (app, proxy, process, auth, config)
├── frontend/ # React 19 SPA
├── frontend-overrides/ # EMQX ConnectionConfig page
├── cloudflare/ # Edge Workers
│ ├── mcp-oauth-gateway.js # OAuth 2.1 authorization server
│ └── mcp-direct.js # clean-404 public endpoint
├── docs/
│ ├── architecture.md
│ └── screenshots/
├── tests/ # 26 passing, 11 live-broker
├── Dockerfile
├── docker-compose.yml
├── k8s-deploy.yaml
├── pyproject.toml
└── README_zh-TW.mdChangelog
v1.0.0 (2026-08)
Initial release — full EMQX MCP Admin Bundle
39 MCP tools across 7 categories, built test-first with FastMCP 3.4
Three-level gating — category, tool and per-operation — plus a read-only master switch
Admin console — Dashboard, Tool Manager, Connection, Tokens, Permissions, Logs, Settings
MCP proxy — token-authenticated reverse proxy, MCP server bound to
127.0.0.1Cloudflare Workers — OAuth 2.1 gateway and clean-404 public endpoint
Live-broker fixes — retained topics containing slashes, base64 payloads, and the
202/reason_code 16"no matching subscribers" caseFix — FastMCP's
Dependsentered and exited the pooledhttpx.AsyncClient, closing it after the first tool; every second call in a session failed with "Cannot reopen a client instance". The provider now returnsEmqxHttp, a borrowed handle that is deliberately not a context manager. Pinned bytests/test_client_lifetime.pyFix — tool switches were written to the config file but never reached the subprocess environment, so toggles had no effect on the live MCP surface
Fix — log capture set the handler but not the logger level, so INFO lines were dropped by the root logger before reaching the buffer and the Log Viewer showed nothing
Testing — 26 unit tests, plus 64/64, 29/29 and 15/15 audits against a live EMQX 5.8.8
Support
Issues: GitHub Issues
Email: service@woowtech.io
Sibling project: woow_n8n_mcp_server
License
MIT — see LICENSE.
Available Tools
39 toolsemqx_authz_settingsAuthorization SettingsARead-onlyIdempotent
Global authorization behaviour: what happens when no rule matches, what a denial does to the connection, and cache settings.
Inputs: no named parameters. Returns: object
emqx_banBan MQTT ClientADestructiveIdempotent
[DESTRUCTIVE] Block a client id, username or IP from connecting.
Unlike emqx_kick_client this persists — the target stays out until the ban expires or is lifted with emqx_unban.
Inputs: who (required), subject_type (required), until, reason. Returns: object
emqx_broker_statsEMQX Broker StatsARead-onlyIdempotent
Live counters for the whole cluster.
Answers "how many devices are online" and "how many subscriptions exist" in one call — cheaper than listing clients.
Inputs: no named parameters. Returns: Live broker counters.
emqx_client_subscribeSubscribe Client To TopicADestructiveIdempotent
[DESTRUCTIVE] Add a subscription to a client on its behalf.
The device does not know this happened and will not re-create the subscription after a clean-session reconnect.
Inputs: topic (required), clientid (required), qos. Returns: object
emqx_client_subscriptionsEMQX Client SubscriptionsARead-onlyIdempotent
Which topics one client is subscribed to, with QoS.
Use this when a device "isn't receiving anything" — usually the subscription filter does not match the publish topic.
Inputs: clientid (required). Returns: object
emqx_client_unsubscribeUnsubscribe Client From TopicADestructiveIdempotent
[DESTRUCTIVE] Remove a subscription from a client on its behalf.
The device stops receiving that topic immediately and has no way to tell why.
Inputs: topic (required), clientid (required). Returns: object
emqx_cluster_statusEMQX Cluster StatusARead-onlyIdempotent
Nodes in the EMQX cluster with version, uptime and resource use.
Start here when asked "is EMQX healthy", "what version is running", or before any node-specific call, to learn valid node names.
Inputs: no named parameters. Returns: object
emqx_create_traceCreate EMQX TraceA
Start capturing MQTT packets for one client, topic or IP.
Traces are the tool for "the device connects but nothing arrives". Note the capture buffers in memory and only flushes to disk when it expires — let it run to completion before reading the log, and do not delete it early or the buffered events are lost.
Inputs: name (required), target (required), target_type (required), duration_seconds. Returns: object
emqx_delete_retainedDelete Retained MessageADestructiveIdempotent
[DESTRUCTIVE] Delete the retained message for one topic.
This is the correct way to remove a stale Home Assistant discovery entry or a ghost device state. It cannot be undone — the previous payload is gone unless something republishes it.
Inputs: topic (required). Returns: object
emqx_delete_traceDelete EMQX TraceADestructiveIdempotent
[DESTRUCTIVE] Delete a trace and its captured log.
If the trace is still running, events buffered in memory are lost. Call emqx_get_trace_log first, or wait for it to expire naturally.
Inputs: name (required). Returns: object
emqx_get_clientGet EMQX ClientARead-onlyIdempotent
Full session detail for one client: keepalive, in-flight queue, protocol, subscriptions count, connect and disconnect timestamps.
Inputs: clientid (required). Returns: object
emqx_get_retainedGet Retained MessageARead-onlyIdempotent
Read the retained message stored for one topic.
Returns found=false when the topic has nothing retained — that is an ordinary answer, not an error.
Inputs: topic (required). Returns: A retained message, with its payload already decoded.
emqx_get_rule_metricsEMQX Rule MetricsARead-onlyIdempotent
Match, pass and failure counters for one rule.
A rule with matched=0 is not seeing traffic — usually its FROM topic filter does not match what devices actually publish.
Inputs: rule_id (required). Returns: object
emqx_get_trace_logRead EMQX Trace LogARead-onlyIdempotent
Read what a trace captured.
A trace that is still running usually returns little or nothing — its events sit in an in-memory buffer until the trace expires.
Inputs: name (required), max_bytes. Returns: object
emqx_kick_clientKick Out MQTT ClientADestructiveIdempotent
[DESTRUCTIVE] Disconnect an MQTT client and clear its session.
Queued and in-flight messages for that session are lost. Devices with auto-reconnect usually return within seconds, so this forces a fresh connection rather than blocking one — use emqx_ban to keep a client out.
Inputs: clientid (required). Returns: Outcome of disconnecting a client.
emqx_list_actionsList EMQX ActionsARead-onlyIdempotent
Outbound actions (bridges) attached to rules, with their health.
Inputs: no named parameters. Returns: object
emqx_list_alarmsEMQX AlarmsARead-onlyIdempotent
Broker alarms — the first place to look when something feels wrong.
Inputs: active_only. Returns: object
emqx_list_authnList EMQX AuthenticatorsARead-onlyIdempotent
The authenticator chain and each authenticator's status.
Check this first when devices cannot connect: an empty chain means EMQX has no user table, so every credential is rejected.
Inputs: no named parameters. Returns: object
emqx_list_authz_sourcesList Authorization SourcesARead-onlyIdempotent
Authorization sources in evaluation order.
Order matters: the first source that returns allow or deny wins.
Inputs: no named parameters. Returns: object
emqx_list_bannedList Banned ClientsARead-onlyIdempotent
Everything currently barred from connecting, with expiry times.
Inputs: no named parameters. Returns: object
emqx_list_clientsList EMQX ClientsARead-onlyIdempotent
List MQTT clients known to the broker.
Answers "which devices are online", "is device X connected", and
"who is connecting from this IP". Walk large fleets by raising
page rather than limit.
Inputs: page, limit, clientid, username, +2 more. Returns: A page of MQTT clients.
emqx_list_connectorsList EMQX ConnectorsARead-onlyIdempotent
Data-integration connectors and their connection health.
Open-source EMQX ships HTTP and MQTT connectors; the rest are enterprise-only, so an empty list is normal on OSS.
Inputs: no named parameters. Returns: object
emqx_list_listenersList EMQX ListenersARead-onlyIdempotent
Listeners, their bind addresses and running state.
This is where you confirm that 1883 (MQTT), 8883 (TLS), 8083/8084 (WebSocket) are actually up before blaming a device.
Inputs: no named parameters. Returns: object
emqx_list_retainedList Retained MessagesARead-onlyIdempotent
Retained messages the broker is holding.
Retained messages are the broker's memory of "last known value" per topic — stale ones are a common cause of ghost entities appearing after a device is removed.
Inputs: page, limit. Returns: object
emqx_list_rulesList EMQX RulesARead-onlyIdempotent
Rule-engine rules, with their SQL and enabled state.
Rules are how EMQX forwards or reshapes messages without any code — for example pushing selected topics into a database or a webhook.
Inputs: page, limit, enabled_only. Returns: object
emqx_list_subscriptionsList EMQX SubscriptionsARead-onlyIdempotent
Every subscription in the cluster, with filters.
match_topic is the powerful one: given a concrete topic, it tells
you exactly which subscribers would receive a message on it.
Inputs: qos, page, limit, topic, +2 more. Returns: object
emqx_list_topicsList EMQX TopicsARead-onlyIdempotent
Topics that currently have a route (i.e. at least one subscriber).
A topic missing here means nobody is subscribed — which is the usual reason a published message appears to vanish.
Inputs: page, limit, topic. Returns: object
emqx_list_tracesList EMQX TracesARead-onlyIdempotent
Packet traces defined on the broker, with status and expiry.
Inputs: no named parameters. Returns: object
emqx_manage_authn_usersManage EMQX MQTT UsersADestructive
Manage MQTT accounts in the EMQX built-in authentication database.
These are the credentials devices use to connect to the broker — not the dashboard login. If no authenticator exists yet, create one in the EMQX dashboard first; without it there is no user table to write to.
Operations enabled on this server: create, delete, read.
Inputs: operation (required), user_id, password, is_superuser, +1 more. Returns: Result of a built-in-database user operation.
emqx_manage_authz_rulesManage EMQX ACL RulesADestructive
Read and write built-in-database ACL rules that decide which topics a user or client may publish to and subscribe to.
Operations enabled on this server: create, delete, read.
Inputs: operation (required), rules, subject, subject_type. Returns: object
emqx_metrics_currentEMQX Current MetricsARead-onlyIdempotent
Current throughput gauges (messages/s in and out, dropped, rate).
Inputs: no named parameters. Returns: object
emqx_metrics_historyEMQX Metrics HistoryARead-onlyIdempotent
Time-series metrics over a recent window.
Use this to answer "was there a spike", "when did connections drop", or to correlate an incident with broker load.
Inputs: latest_seconds. Returns: object
emqx_node_detailEMQX Node DetailARead-onlyIdempotent
Everything EMQX knows about one node.
Inputs: node (required). Returns: object
emqx_prometheus_statsEMQX Prometheus StatsARead-onlyIdempotent
Raw Prometheus exposition text. Large — prefer emqx_broker_stats.
Inputs: max_bytes. Returns: object
emqx_publishPublish MQTT MessageA
Publish a single MQTT message through the broker.
This is how you drive a device or feed a Home Assistant MQTT entity. Publishing to a topic nobody subscribes to succeeds silently — check emqx_list_subscriptions if nothing reacts.
Inputs: topic (required), payload (required), qos, retain, +1 more. Returns: object
emqx_publish_bulkPublish MQTT Messages In BulkA
Publish a batch of MQTT messages in one round trip.
Prefer this over many emqx_publish calls when seeding several topics — for example bringing up a set of test entities.
Inputs: messages (required). Returns: object
emqx_test_rule_sqlTest EMQX Rule SQLARead-onlyIdempotent
Dry-run rule SQL against a sample event. Nothing is saved.
Use this to check a rule before creating it — it reports whether the event would match and what columns the rule would emit.
Inputs: sql (required), topic, payload. Returns: object
emqx_toggle_ruleEnable Or Disable EMQX RuleADestructiveIdempotent
[DESTRUCTIVE] Switch a rule on or off.
Disabling a rule silently stops whatever pipeline depends on it — confirm which downstream system it feeds before turning it off.
Inputs: enable (required), rule_id (required). Returns: object
emqx_unbanUnban MQTT ClientADestructiveIdempotent
[DESTRUCTIVE] Lift a ban so the client may connect again.
Inputs: who (required), subject_type (required). Returns: object
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 39 tools
Each tool targets a distinct resource and action, with clear separation even among similar metrics tools (broker_stats vs metrics_current vs metrics_history). Descriptions explicitly note overlaps and guide selection, so misselection is unlikely.
Most tools follow a consistent emqx_<verb>_<noun> pattern (emqx_list_clients, emqx_kick_client, emqx_delete_retained), but a few deviate with noun-verb ordering (emqx_client_subscribe) or generic verbs like manage (emqx_manage_authn_users). Still predictable and readable overall.
39 tools is far above the typical well-scoped range and likely overwhelms agents. While the EMQX domain is broad, the granularity (e.g., separate metrics_current and metrics_history) could be consolidated without loss of function.
The surface covers major EMQX workflows: monitoring, clients, subscriptions, retained messages, rules, traces, authn/authz, and bans. Minor gaps exist, such as no update for authn users and no retain flag on publish, but these are workable and do not break core use cases.
Maintenance
Related MCP Connectors
MCP server with quote and live cryptocurrency price tools, local and cloud-deployed transports.
MCP gateway and cloud hosting for MCP servers, agents and apps like RAG, managed from your agent.
The MCP server that vets MCP servers: identity, risk grade and per-tool risk before you install.
MCP gateway with runtime security policy, tool-call-level control, and audit of agent actions.
Related MCP Servers
- FlicenseNot gradedqualityDmaintenanceAn MCP server that enables interaction with multiple Kubernetes clusters simultaneously, providing comprehensive tools for cluster management, resource operations, and diagnostics across different environments.-
- AlicenseNot gradedqualityDmaintenanceAn MCP server that enables interaction with Kafka clusters to manage topics, monitor consumer groups, and stream messages. It provides a comprehensive suite of tools for broker metadata inspection and local Kafka user management.MIT
- AlicenseNot gradedqualityAmaintenanceAn MCP server that enables AI assistants to safely interact with Apache Kafka clusters, providing tools for topic management, message operations, consumer groups, and cluster information.3MIT
- AlicenseNot gradedqualityDmaintenanceEnterprise-grade MCP server for Kubernetes operations, supporting local kubeconfig and AWS EKS authentication, with read, write, and destructive operations protected by multi-layer safety guards.MIT