Skip to main content
Glama

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 127.0.0.1 only

"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; destructiveHint is set on all 13 dangerous tools

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 docker logs

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 content

Three-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:#046B48

Module 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:#9AA0AA

registry.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:#046B48

More 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

emqx_cluster_status

Cluster nodes with version, uptime, CPU and memory

emqx_node_detail

Full detail for one node, including load and connection counts

emqx_broker_stats

Live counters: connections, sessions, subscriptions, topics

emqx_metrics_current

Current throughput gauges used by the dashboard charts

emqx_metrics_history

Time-series metrics over a recent window

emqx_list_alarms

Active and historical broker alarms

emqx_prometheus_stats

Raw Prometheus exposition text for scraping or diffing

Client Management (6)

Tool

Description

Destructive

Operations

emqx_list_clients

MQTT clients known to the broker, with filters

read

emqx_get_client

Full session detail for a single client

read

emqx_client_subscriptions

Topics a specific client is subscribed to

read

emqx_kick_client

Forcibly disconnect a client and clear its session

⚠

delete

emqx_client_subscribe

Subscribe a client to a topic on its behalf

⚠

create

emqx_client_unsubscribe

Remove a subscription from a client on its behalf

⚠

delete

Topics & Subscriptions (2)

Tool

Description

emqx_list_topics

Routed topics currently present in the cluster

emqx_list_subscriptions

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

emqx_publish

Publish one MQTT message through the broker

⚠

create

emqx_publish_bulk

Publish a batch of messages in one call (max 50)

⚠

create

emqx_list_retained

Retained messages the broker is holding

read

emqx_get_retained

The retained message stored for one topic

read

emqx_delete_retained

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

emqx_list_authn

Authenticator chain and each authenticator's status

read

emqx_manage_authn_users

List, create and delete MQTT users in the built-in database

⚠

create, delete, read

emqx_list_authz_sources

Authorization sources and their order in the chain

read

emqx_authz_settings

Global authorization behaviour: no-match, deny action, cache

read

emqx_manage_authz_rules

Read and write built-in-database ACL rules

⚠

create, delete, read

emqx_list_banned

Current ban list entries

read

emqx_ban

Ban a client id, username or IP from connecting

⚠

create

emqx_unban

Lift a ban

⚠

delete

Diagnostics (5)

Tool

Description

Destructive

Operations

emqx_list_traces

Packet traces currently defined on the broker

read

emqx_create_trace

Start a packet trace for a client, topic or IP

⚠

create

emqx_get_trace_log

Read the captured log for a trace

read

emqx_delete_trace

Delete a trace; unflushed events are lost

⚠

delete

emqx_list_listeners

Listeners with bind addresses and running state

read

Data Integration (6)

Tool

Description

Destructive

Operations

emqx_list_rules

Rule-engine rules with their SQL and enabled state

read

emqx_get_rule_metrics

Match, pass and failure counters for one rule

read

emqx_toggle_rule

Enable or disable a rule

⚠

update

emqx_test_rule_sql

Dry-run rule SQL against a sample event, saving nothing

read

emqx_list_connectors

Data-integration connectors and their health

read

emqx_list_actions

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-admin

Open http://localhost:8080 and log in. Configure the broker on the Connection page.

Option 2: Docker Compose

docker compose up -d
helm install emqx-mcp charts/emqx-mcp -n emqx-mcp --create-namespace
kubectl -n emqx-mcp get pods

charts/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 pods

The 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 8080

Configuration

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

MCP_ADMIN_CONFIG

/data/config.json

Path to the configuration file

JWT_SECRET

random

JWT signing secret — set this in production, or sessions break on restart

JWT_EXPIRY_HOURS

24

Admin session lifetime

EMQX_MCP_BASE_URL

—

EMQX dashboard base URL (no /api/v5)

EMQX_MCP_API_KEY

—

EMQX API key

EMQX_MCP_API_SECRET

—

EMQX API secret

EMQX_MCP_READONLY

false

Master switch: drop every destructive tool

EMQX_MCP_DISABLED_TOOLS

empty

Comma-separated tool names

EMQX_MCP_DISABLED_CATEGORIES

empty

Comma-separated category names

EMQX_MCP_DISABLED_OPERATIONS

{}

JSON map of tool → disabled operations

EMQX_MCP_DEFAULT_LIMIT

50

Default page size for list tools

EMQX_MCP_MAX_LIMIT

200

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

mcp-direct.js

https://host/mcp or https://host/private_{token}/mcp

path token only

You want the same shape as the rest of the WOOWTECH MCP fleet

mcp-oauth-gateway.js

https://host/mcp

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

POST

/api/auth/login

Authenticate with the admin password, returns a JWT

Dashboard

Method

Endpoint

Description

GET

/api/health

Aggregated health: MCP server, broker, proxy, version, node, connections

Connection

Method

Endpoint

Description

GET

/api/config

Current connection settings, secret masked

PUT

/api/config/connection

Update base URL, API key, API secret

POST

/api/config/test

Live connectivity test; returns edition, version, node count

PUT

/api/config/permissions

Replace the allow/deny tool policy

Tools

Method

Endpoint

Description

GET

/api/tools

All 39 tools with category, description and enabled state

PUT

/api/tools

Apply a tool enable/disable set and restart the subprocess

PUT

/api/tools/operations

Update per-tool disabled operations

Tokens

Method

Endpoint

Description

GET

/api/tokens

Current token, masked, plus rotation history

POST

/api/tokens/rotate

Generate, apply and restart in one step

PUT

/api/tokens

Set a specific token value

Logs

Method

Endpoint

Description

GET

/api/logs/stream

SSE stream, replays the recent buffer then follows

GET

/api/logs/search

Text or regex search across the ring buffer

Settings

Method

Endpoint

Description

GET

/api/settings

Full config with secrets masked

PUT

/api/settings/{section}

Replace one config section

GET

/api/settings/mcp/status

Subprocess status: running, pid, restart count

POST

/api/settings/mcp/restart

Restart the MCP subprocess

System

Method

Endpoint

Description

GET

/healthz

Kubernetes-compatible health check, unauthenticated

ANY

/private_{token}/mcp/

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:#B4231F

Security 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_details is on; unexpected exceptions become a generic message while deliberate ToolErrors 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

  1. Change admin_password on first login.

  2. Set JWT_SECRET explicitly.

  3. Rotate mcp_auth_token after handing it to anyone, and use the OAuth gateway for public exposure rather than the path token.

  4. Give the bundle an EMQX API key scoped to what it actually needs.

  5. Turn on readonly for assistants that only need to observe.

  6. Put the MCP endpoint on its own hostname — never share one with the admin GUI.


Testing

pip install -e ".[dev]"
pytest -v

Test file

Covers

Tests

test_gating.py

Three-level gating and the read-only switch

7

test_mcp_surface.py

Registration, annotations, registry ↔ server parity

6

test_admin_tools_api.py

/api/tools shapes and the subprocess-env write path

4

test_connection_wiring.py

Config keys reach the subprocess as EMQX_MCP_*

3

test_retained_topics.py

Slash-in-topic fallbacks, base64 payloads

3

test_log_stream.py

Log capture, classification and SSE framing

1

test_client_lifetime.py

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 AsyncClient

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.md

Changelog

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.1

  • Cloudflare 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" case

  • Fix — FastMCP's Depends entered and exited the pooled httpx.AsyncClient, closing it after the first tool; every second call in a session failed with "Cannot reopen a client instance". The provider now returns EmqxHttp, a borrowed handle that is deliberately not a context manager. Pinned by tests/test_client_lifetime.py

  • Fix — 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


License

MIT — see LICENSE.


Available Tools

39 tools
emqx_authz_settingsAuthorization SettingsA
Read-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 ClientA
DestructiveIdempotent

[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 StatsA
Read-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 TopicA
DestructiveIdempotent

[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 SubscriptionsA
Read-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 TopicA
DestructiveIdempotent

[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 StatusA
Read-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 MessageA
DestructiveIdempotent

[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 TraceA
DestructiveIdempotent

[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 ClientA
Read-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 MessageA
Read-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 MetricsA
Read-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 LogA
Read-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 ClientA
DestructiveIdempotent

[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 ActionsA
Read-onlyIdempotent

Outbound actions (bridges) attached to rules, with their health.

Inputs: no named parameters. Returns: object

emqx_list_alarmsEMQX AlarmsA
Read-onlyIdempotent

Broker alarms — the first place to look when something feels wrong.

Inputs: active_only. Returns: object

emqx_list_authnList EMQX AuthenticatorsA
Read-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 SourcesA
Read-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 ClientsA
Read-onlyIdempotent

Everything currently barred from connecting, with expiry times.

Inputs: no named parameters. Returns: object

emqx_list_clientsList EMQX ClientsA
Read-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 ConnectorsA
Read-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 ListenersA
Read-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 MessagesA
Read-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 RulesA
Read-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 SubscriptionsA
Read-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 TopicsA
Read-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 TracesA
Read-onlyIdempotent

Packet traces defined on the broker, with status and expiry.

Inputs: no named parameters. Returns: object

emqx_manage_authn_usersManage EMQX MQTT UsersA
Destructive

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 RulesA
Destructive

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 MetricsA
Read-onlyIdempotent

Current throughput gauges (messages/s in and out, dropped, rate).

Inputs: no named parameters. Returns: object

emqx_metrics_historyEMQX Metrics HistoryA
Read-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 DetailA
Read-onlyIdempotent

Everything EMQX knows about one node.

Inputs: node (required). Returns: object

emqx_prometheus_statsEMQX Prometheus StatsA
Read-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 SQLA
Read-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 RuleA
DestructiveIdempotent

[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 ClientA
DestructiveIdempotent

[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

    A3.9/5.0

    Scored across 39 tools

    Disambiguation5/5

    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.

    Naming Consistency4/5

    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.

    Tool Count2/5

    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.

    Completeness4/5

    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

    ActivitySlowing
    ResponsivenessOutdated data

    Related MCP Connectors

    Related MCP Servers

    • F
      license
      Not graded
      quality
      D
      maintenance
      An MCP server that enables interaction with multiple Kubernetes clusters simultaneously, providing comprehensive tools for cluster management, resource operations, and diagnostics across different environments.
      -
    • A
      license
      Not graded
      quality
      D
      maintenance
      An 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
    • A
      license
      Not graded
      quality
      A
      maintenance
      An 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.
      3
      MIT
    • A
      license
      Not graded
      quality
      D
      maintenance
      Enterprise-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