Read-only access to an MQTT broker's topic tree, over MQTT 5.0 (issue #1635).
Every rule below is followed by its evidence: a measurement we ran, a source line in this repository, a clause of the OASIS MQTT 5.0 specification, or a cited line in a third-party implementation at a named version. Where a rule is a choice rather than a measurement, it is marked CHOICE and its cost is stated.
| Write reach | none: the encoder has no PUBLISH function, and the valve, guardOutboundPacket, refuses any other packet on the bytes; there is no object edit, maintenance or transaction route |
Read this section first. It explains the one thing about this provider that differs from every other engine Studio connects to.
MQTT has no command that lists topics. There is no SHOW TABLES, no SCAN, no catalog. A client learns a
topic exists only by subscribing to a filter and waiting for a message on it. A topic that nobody published to
during the read is indistinguishable from a topic that does not exist.
That is a property of the protocol, not a gap in this provider. The consequence runs through every screen:
- This provider never says a read was complete. It cannot be established, so it is not claimed.
- It says a read was incomplete only where it can prove it: a refused subscription, a broker that closed the connection, or a message skipped for being over the inbound limit.
- It never infers incompleteness from timing. Measured across twelve cases, the quiet gap before a lossless read ran 214 ms to 4,013 ms and before a lossy one 5,954 ms to 19,894 ms: the two sets do not separate, and the ordering is inverted. A silence threshold would have been wrong in both directions.
Agent mode reads a broker under the same rule: every agent answer states the window it listened for and that a topic not seen in it may still exist (section 14).
A connection subscribes once to a discovery filter, collects what the broker delivers until one of Studio's own budgets ends the read, and disconnects. The topic names become the tree in the Keys panel. Clicking a topic reads that one topic's payload.
| MQTT | Studio |
|---|---|
| Broker | Connection |
| Topic | Key, in the Keys panel |
Topic level (a/b/c) |
Folder in the tree, / as the separator |
| Retained message | A row that exists without live traffic |
| Message Expiry Interval | The row's type cell, as the server reported it |
| Payload | The topic's Source tab |
$SYS |
Not read at all unless the filter names it: # never matches a $ root |
A broker has no tables, no indexes, no schema, no sessions a client can list and no statistics to read. Every surface that would show one says so rather than showing an empty panel.
src/lib/db/providers/keyvalue/mqtt/, beside Redis, etcd and Oxia. The Keys panel is the surface; there is no
query console in this version.
| File | What it holds |
|---|---|
constants.ts |
Every number and fixed name, in one pure module |
encoder.ts |
The five packets this provider can write |
valve.ts |
The one-way valve, outbound and inbound |
session.ts |
One connection and one read over it |
key-scan.ts |
The Keys panel's capability and its page |
objects.ts |
The topic object kind and the Source tab |
labels.ts |
The provider's labels |
agent-statement.ts |
Agent mode's reader of one read request, and its closed table of two reads |
agent-read.ts |
One agent read as one fresh session, its metadata-only answer and its failure wording |
agent-surface.ts |
What every other surface of an agent-mode handle answers instead |
index.ts |
The provider class |
connect() performs the read, and the Keys panel's first page is drawn from it. Opening a folder or typing a
prefix narrows the read already held and issues no request; the refresh button performs a new read that
replaces the tree (section 5). Two reads are never merged into one tree.
The decoder is mqtt-packet 9.0.2, imported by the subpath mqtt-packet/parser.js and never by the package
root. The distinction is the point, and an earlier version of this provider got it wrong. The package root is
three lines:
exports.parser = require('./parser').parser
exports.generate = require('./generate')
exports.writeToStream = require('./writeToStream')Importing the root puts the packet writer in the build, one call away from a PUBLISH, and three reviewers in
turn loaded that module and generated one to show it: 30060003612f6278, a real eight-byte PUBLISH. The
subpath is module.exports = Parser, a class carrying the parser factory as a static, and generate and
writeToStream are both undefined on it. tests/unit/db/mqtt/outbound.test.ts asserts that from the
loaded module, and asserts the root carries both, so the difference between the two paths is pinned rather
than described. src/types/db-drivers.d.ts declares only the subpath's own surface, so a later generate(...)
does not compile.
It was chosen for the reason that matters in a module parsing bytes from an untrusted source: it is the decoder with by far the widest deployment among MQTT packet parsers on npm, and it carries no advisory that applies to 9.0.2. The encoder, the connection manager and the valve are this repository's own.
encoder.ts exports functions for CONNECT, SUBSCRIBE, UNSUBSCRIBE, PINGREQ and DISCONNECT. There is no function
that writes a PUBLISH, and encodeConnect takes no Will parameter, so neither can be produced by a caller, a
configuration mistake or a later edit that forgets the rule. The write reach is therefore none whatever the
connection's readOnly says, and the dialog draws a fixed Read-only label with no checkbox.
valve.ts re-checks the bytes themselves before they reach the socket. The encoder is our code; the valve is
about everything that is not: a library upgrade, a transport that retries a frame, a future helper. It refuses
any packet type outside the five, and refuses a CONNECT whose Will flag is set.
CHOICE. Declaring a Maximum Packet Size in CONNECT makes the loss invisible: "Where a Packet is too large to send, the Server MUST discard it without sending it and then behave as if it had completed sending that Application Message" [MQTT-3.1.2-25].
The limit is 8 MiB, MQTT_MAX_INBOUND_BYTES in constants.ts, and a message over it is skipped by byte count
without its body ever being buffered. Measured both ways on the same server with the same retained message,
whose payload is 12 MiB, comfortably over the limit:
| Result | What Studio reports | |
|---|---|---|
| Limit declared in CONNECT | The broker silently discards the message | "nothing missing" |
| Limit not declared, enforced by us | We read the header, skip the body, never allocate | The message on "buyuk/olcum" is 12,582,926 bytes, larger than this connection reads, and was skipped. |
The figure is the packet's Remaining Length, which is the number the header declares and the only one in hand when the decision is made; the frame on the wire is five bytes longer.
A message at or under 8 MiB is delivered and read normally; the limit exists for the one that is not.
The limit has to live in the valve because no package can carry it. Measured across twenty-six packages, in
none of them can an inbound bound of our own be applied before memory is committed. Two do check before
allocating and neither can carry this work: @seriousme/opifex 2.3.0 hardcodes 4 KB, does not expose it on its
public API and reconnects by itself; @qualithm/mqtt-wire 0.3.1 checks correctly but is a server library with
no client state machine.
No work is done on the receive path. Bytes come off the socket as fast as they arrive and are processed separately, so the broker never sees Studio as slow and never starts dropping on its behalf. If our own buffer fills, that ends the read cleanly rather than pushing back on the broker: pushing back is what turns a visible end into a silent loss. This is also why the tree holds names only and why every read carries a budget.
The five-packet set is conforming only at QoS 0. At QoS 1 the receiver MUST answer each message with a PUBACK, and at QoS 2 with PUBREC and PUBCOMP (section 4.3), none of which this provider can write, so a higher QoS would stall the read instead of failing visibly.
$share/, $queue/ and $exclusive/ are refused, case sensitively. A shared subscription hands each message
to exactly one member of the group, so a read-only tool silently eats commands meant for a device: in our lab,
of twenty commands published to a shared filter, ten went to Studio and never reached the device. No broker
reports that it happened, and Mosquitto, EMQX and HiveMQ all answer SUBACK Granted, so the check has to
happen before the packet is sent.
The comparison is case sensitive because $SHARE/... in uppercase is an ordinary topic carrying real data.
CHOICE. The provider answers cursor: "0", which KeyScanPage in types.ts defines as "this walk reached the end". Cost:
the panel's "Scan more" is disabled, so a reader cannot ask for the rest.
The alternative was worse. A live cursor would make each "Scan more" open a second connection and merge its answer into the first tree, and two half-reads merged produce a tree that never existed on the server at any instant: in our measurement read one held A, B, C; between the reads A was deleted and D was added; read two held B, C, D; the merge showed four topics where the server never had more than three.
CHOICE. # never matches a topic whose first character is $ ([MQTT-4.7.2-1]), so a default read does not
reach $SYS at all and no $SYS branch appears in the tree. Nothing in this provider special-cases it; the
protocol does. Cost: a reader who wants broker statistics types $SYS/# by hand, and it is then an ordinary
first level like any other. Reasons: $SYS/# is the widest form the tree can take, it changes
under the reader with no action of theirs, and on EMQX it carries each device's client id, username and IP
address, so a default that pulled it would put that on screen for anyone who opens the panel.
| Field | Meaning |
|---|---|
| Host | The broker's name or address. In a container, localhost is that container: use host.docker.internal |
| Port | 1883 plain, 8883 with TLS. The IANA assignment for MQTT |
| User / Password | A broker credential, if the broker checks one |
| Topic filter | What to subscribe to. Empty means #. Carried in the Database field, as Oxia's namespace is |
| Allow insecure authentication | The consent for a password sent with no SSL mode to a host that is not this machine, which is refused without it. See 4.4 |
| SSL / TLS | The transport. disable is plain TCP. See 4.5 |
There is no connection-string box. Host and port say everything a mqtt:// URI would.
The client id is the fixed prefix libredb- plus a mandatory cryptographically random suffix of eight bytes,
rendered as sixteen hex characters. Neither half is a setting: there is no client-id field, and changing the
prefix means changing MQTT_CLIENT_ID_PREFIX in constants.ts. A broker that enforces clientid_prefixes
and refuses this one answers CONNACK 0x85, which Studio names in full.
The suffix is what matters. MQTT ends the older session when a second connection arrives with the same id (Session Taken Over, 0x8E, section 3.1.4), so a colliding id disconnects whoever connected first.
This is the shape the reference client uses, with a weaker suffix and a fixed prefix: mosquitto_sub -I is
implemented as snprintf(cfg->id, ..., "%s%d", cfg->id_prefix, getpid())
(mosquitto/client/client_shared.c, v2.1.2). Our suffix is random rather than the process id, because
process ids are small and repeat across container restarts.
A zero-length client id, which makes the server assign one, is not offered.
Clean Start with a Session Expiry Interval of zero. Nothing is left behind for the broker to queue for a client
that is not coming back, which is itself one of the ways a broker silently drops a device's messages. Both are
asserted byte by byte off the written CONNECT in tests/unit/db/mqtt/outbound.test.ts, because neither is
visible in a replayed capture: a capture records what a correct CONNECT produced.
A password sent with no SSL mode crosses the network in cleartext. Studio refuses that combination unless the host is this machine or an SSH tunnel carries the connection, and the Allow insecure authentication box is the override for a reader who has a reason. The remedy the hint names first is an SSL mode, not the override.
The five SSL modes mean here exactly what they mean everywhere else in Studio, which is the rule stated in
src/lib/types.ts and followed by Redis, MongoDB, Couchbase, Kafka and Prometheus:
| Mode | Transport | The broker's certificate |
|---|---|---|
disable |
Plain TCP | Not used |
require |
TLS | Not checked. A self-hosted broker ordinarily presents a self-signed certificate, and refusing it would make the ordinary local TLS deployment unreachable |
verify-system |
TLS | Checked against the runtime's own trust store, with nothing to paste |
verify-ca |
TLS | Checked against the CA certificate pasted into the panel |
verify-full |
TLS | Checked, including the host name |
An explicit rejectUnauthorized on the connection overrides the mode's default in either direction.
Three details are worth knowing:
- The panel's three certificate boxes are used. A CA certificate, a client certificate and its private key
are passed to the TLS dial as
ca,certandkey. A broker signed by a private CA is reachable inverify-cawith the CA pasted in, and a broker that asks for a client certificate gets one. - A broker addressed by an IP literal verifies against its IP SAN. The server name and the identity are
two different things. Node refuses an IP address as a TLS server name, so a connection to
10.0.0.5sends no server name at all, and the certificate is checked against10.0.0.5itself, which reads the certificate's IP SAN. A hostname is sent as typed, because SNI reads it. - Through an SSH tunnel the certificate is checked against the far end. The socket dials the local
forward, and the certificate belongs to the broker the tunnel reaches, so the forward's address is not the
identity. This is the same rule
tlsAgentOptionsapplies for every provider that speaks HTTP. - A failed verification says so. In a
verify-mode a self-signed broker fails with the certificate error rather than a timeout, so the cause is on screen rather than inferred.
Offered, and it works as it does for every other engine: the tunnel carries the TCP connection and the broker sees a local client. It is also the remedy the password hint names for a broker with no TLS, because a tunnel encrypts the credential the broker cannot.
With TLS on as well, the certificate is checked against the tunnel's far end rather than against the local forward, which is the second bullet in 4.5.
With DB_HTTP_BLOCK_PRIVATE_HOSTS on, a tunnelled connection is refused, for the reason in 4.8.
MQTT 5.0 only. The CONNECT declares version 5 and the decoder is built for version 5, so a broker that does not speak 5.0 refuses the handshake rather than half-working: a 5.0 broker refusing the version answers 0x84 (Unsupported Protocol Version), and a 3.1.1 broker answers the older CONNACK's return code 1. Studio names both. A code outside the fourteen in 10.1 is reported as itself in both bases. The brokers measured are in section 8.
The dial applies the shared egress rule, socketEgress in src/lib/db/http/egress-policy.ts, and writes no address check of its own.
A link-local address or AWS's IPv6 instance metadata address (169.254.0.0/16, also in its IPv4-mapped and NAT64 forms, fe80::/10, fd00:ec2::254, and any IPv6 address with a zone index) is never reached, whatever DB_HTTP_BLOCK_PRIVATE_HOSTS says.
With the flag on, a loopback, private, unique-local or special-use address is refused as well, as it is for the HTTP transports and RabbitMQ's AMQP dial.
An IP literal is refused before any socket opens, and a name is dialled through a lookup that checks every address it resolves to and hands the socket the address it checked, so a second lookup cannot answer differently.
The refusal is the shared sentence, unchanged, under plain TCP and TLS alike.
Through an SSH tunnel the socket dials the local forward, a loopback address, and the rule is checked against that address, as the shared node transport checks it. So with the flag on a tunnelled connection is refused, and with it off the forward is dialled. The broker at the far end is not checked here: a name is resolved by the SSH server, where Studio cannot check it.
mqtt has no connection string scheme, no readTls, no paste and no seed load check: every field of its row in CONNECTION_STRING is null (connection-string.ts).
The rolled-back notice names COMMIT as the keyword that keeps an open transaction, and mqtt has no consent deadline, so the agent's consent card shows no deadline chip or sentence for it.
Its row in EXECUTION_UI is declared in execution-ui.ts.
What a tree click and Generate Query write is MQTT_GENERATORS, the mqtt row of DIALECT_GENERATORS, declared in generators.ts and read by the engine-agnostic dispatcher in src/lib/query-generators.ts.
A read ends on one of Studio's own budgets: elapsed time (3 seconds), message count (50,000) or bytes (32 MiB).
It never ends on an inferred signal, and the retain flag is a per-row label rather than a stop signal. Two of
these three had no test that could produce them until a mutation run found that out; all three now do, in
tests/unit/db/mqtt/session.test.ts.
The time budget ends a read when the monotonic clock the read reports its durations with has run 3 seconds from when the connection was ready, not merely when its timer fires. A timer promises nothing against that clock: Bun's fired early on macOS, where CI measured a 2,999 ms window twice (runs 41381663638 and 41404970621). A timer that fires early is armed again for the rest, so a read the time budget ended never reports a window shorter than the budget.
A read can also end because the broker ended it. The six end reasons and the sentence each produces are in section 10.
Refresh reads the broker again. Opening a connection performs one read, and the Keys panel's first page is drawn from it, so opening costs one read rather than two. A refresh, which asks for the same first page again, performs a new read and replaces the tree, its row types and the sentence that explains it. A first page with a different pattern is the panel narrowing what it shows, and is answered from the read already held: typing a prefix opens no connection. This is what makes "Reading again, or narrowing the topic filter, sees more" true: a topic the broker retained after the connection opened appears on a refresh, and one it no longer retains disappears. There is no second page to ask for, by the choice in 3.7.
The retain flag is not a stop signal because it cannot be one. Measured in the lab, against the broker versions named:
| Broker | Retained topics present | Delivered when the first non-retained message arrived |
|---|---|---|
| Mosquitto 2.1.2 | 10,005 | 10,005 |
| HiveMQ CE | 10,000 | 650 |
| EMQX 5.8.9 | about 10,000 | 1,000 |
The second column is what a client would have had to trust for "the retained set has arrived" to mean the read is done. One broker delivered everything, one stopped at 650 of 10,000 and one at 1,000: there is no number a client can wait for.
Every read reports what it saw, why it stopped, and what it can prove it lost. scope has two values,
incomplete and unknown; complete is not one of them. MQTT_KEY_SCAN.totalScope is none, which tells
the panel not to read the page's total at all; the field itself is 0 on every page, because the shape
requires a number and there is no count a broker publishes.
How this engine's statement text is read is declared once, in src/lib/db/providers/keyvalue/mqtt/sql-grammar.ts, the mqtt row of the SQL grammar registry in src/lib/db/sql-grammar/.
readsSqlText is false: there is no query console in this version (§2.1), the Keys panel is the surface, so no statement text of this engine is SQL.
The confirmation gate therefore answers from this engine's row of NON_SQL_DESTRUCTIVE_VOCABULARY (src/lib/db/providers/keyvalue/mqtt/destructive.ts, collected in src/lib/db/destructive-commands.ts) instead of a SQL reading.
Its grammar is DEFAULT_SQL_GRAMMAR, the compatibility default, since no SQL grammar of its own applies to this text.
Redaction is none: a redacted statement is stored as ?, because a reader guessing where a literal ends in a language it does not read can store the literal.
One object kind, topic, enumerated by the key browser. Declaring it is what opens a topic's payload in a
Source tab rather than a generated read, and it is also what keeps the kind out of the Objects tree, out of
inventory and search, and out of the agent's grounding walk.
Counts are the exception: POST /api/db/objects/counts returns the provider's own answer verbatim, so this
provider simply counts nothing. A broker publishes no count of its topics, and a number produced from what one
read happened to see would be a total with nothing behind it.
Clicking a topic opens a second read subscribed to that one topic, which is the only read that keeps a payload. The tree itself holds names only: holding every payload is the single most effective way to become a slow reader, which is when a broker starts dropping messages on Studio's behalf.
The row's type cell carries the server's own Message Expiry Interval and nothing is derived from it. The wire carries what is left of a message's lifetime, never its age: age would be the publisher's original value minus what arrived, and the first term is never transmitted.
Two of the five brokers in section 8, HiveMQ CE and NanoMQ, do not reduce the value at all. Mosquitto does:
measured on 2.1.2, a message published with an expiry of 3,600 seconds was delivered at 3,588. [MQTT-3.3.2-6], in section 3.3.2.3.3, reads:
"The PUBLISH packet sent to a Client by the Server MUST contain a Message Expiry Interval set to the received
value minus the time that the Application Message has been waiting in the Server." The requirement carries no
exception for retained delivery, and section 3.3.1.3 describes exactly that wait, so HiveMQ CE and NanoMQ
returning the publisher's original value is a departure from the clause. Studio does not branch on it: a client
cannot tell a conforming small number from a non-conforming one, so the number is shown as the server's answer
and the label says so.
MQTT declares no editable object kind and no write path; a topic's Source tab is read-only. This is not a decision that could be reversed by a setting: this provider has no code that can write to a broker.
A broker keeps no catalog and publishes no statistics a subscriber can read, so Health reports what the last read saw and nothing else. There are no slow queries, no active sessions, no table or index statistics and no maintenance operations.
| Broker | Status | Notes |
|---|---|---|
| Mosquitto 2.0.22 / 2.1.2 | Supported | Delivers the retained set in one burst at subscribe time. A broker-side queue limit can truncate a large retained set; raising max_queued_messages fixes it |
| EMQX 5.8.9 / 6.3.1 | Supported, but not with the default filter | Refuses #, +/# and $SYS/# out of the box, with SUBACK 135, from loopback as well as from anywhere else: a stock acl.conf carries {deny, all, subscribe, ["$SYS/#", {eq, "#"}, {eq, "+/#"}]}. So on a stock EMQX the default filter reads nothing and the panel says the subscription was refused; a branch such as sensors/# is granted and reads. A retained message over 1 MB is acknowledged to the publisher and never stored |
| HiveMQ CE | Supported | Does not decrement message expiry, contrary to [MQTT-3.3.2-6] |
| NanoMQ | Supported with a caveat | Sends a MaximumPacketSize one above the largest expressible packet, which a strict client library rejects. Does not decrement message expiry |
| RabbitMQ 4, MQTT plugin | Connects, discovery does not work | Does not deliver retained messages to a wildcard subscription, only to an exact topic. SUBACK is Granted in both cases, so the tree simply comes back empty |
getCapabilities() answers before any connection, and every surface a broker does not have is declared absent
by name rather than left to a default.
| Capability | Value | Why |
|---|---|---|
writeReach |
none | Structural, not a setting (see 3.1): the editor absent with its proof, guardOutboundPacket, and object edit, maintenance and transactions absent |
queryLanguage |
json |
The tab's editor language, shared with Kafka |
queryDialect |
mqtt |
Its own, so a connection never falls through to MongoDB's tab (#427) |
defaultPort |
1883 |
The IANA assignment |
supportsResultPagination |
false |
There is no second page to ask a broker for: see 3.7 |
supportsTransactions |
false |
A broker has no transaction |
declaresForeignKeys |
false |
A broker has no schema |
keyScan |
MQTT_KEY_SCAN |
cursor: "decimal", totalScope: "none", no level scan |
MQTT_LABELS names the entity "Topic" and the row "Message". The three actions a broker cannot perform,
Generate, Analyze and Vacuum, read "Not available" with the reason beside them, because a disabled control
with no reason reads as a defect: Analyze says a broker keeps no catalog and no statistics a client can read,
and Vacuum says maintenance would be a write and this provider has no code that can write to a broker.
A read's completeness cannot be proved, so a read handed to an agent carries that with it, or it is not handed over: an agent asked "is the sensor reporting?" must not receive the absence of a topic as evidence it does not exist, which is exactly the inference "Before you connect" says cannot be drawn.
Agent mode reads a broker on that condition (section 14). Every agent answer states the window it listened for,
what it counted in that window and why it stopped, its scope is never complete, and it says that a topic not
seen in the window may still exist; the tool description, the grounding note and the posture say the same, and
none of them claims that a topic does not exist. Agent mode is metadata only: no topic name, payload or property
reaches it.
MCP reads a broker on the same condition: run_mqtt_read runs agent mode's reads, and every answer carries the
window and the completeness sentence as its notes (section 15).
mqtt/sql-writing.ts declares how a generated statement spells a literal, a name and a bind for this engine, and it is this engine's row of SQL_WRITING (src/lib/db/sql-writing/index.ts), which the readers in src/lib/sql/values.ts and src/lib/sql/identifier.ts look up.
A literal is written by literalStandard and read back by unquoteStandard: the quote is doubled and a backslash is data.
A name is quoted by identifierDoubleQuoted, the SQL standard form: a double-quoted name with an inner double quote doubled.
There is no placeholder (placeholder: null), so a generator quotes the value with quoteLiteral instead of emitting a bind.
No measurement stands behind this row; it uses the standard building blocks named above.
Inline row editing reads a key column's kind through the shared INSTANT_TYPE_NAMES, FLOAT64_TYPE_NAMES and EXACT_DECIMAL_TYPE_NAMES of the module's inlineEdit, and binds every edit as typed.
The module's export field writes a result export's CREATE TABLE and INSERT from the shared building blocks alone: STANDARD_DDL_TYPES for the column types, NOTHING_STANDS_ALONE and NO_BARE_SPELLING, so every bare declared name is re-spelled from its family, NULL_NON_FINITE for NaN and the infinities, and binaryStandardHex for a binary cell (X'0102').
Every failure names a cause, and names a remedy wherever one exists.
CONNACK carries a reason code, and connackSentence() turns thirteen of them into a sentence. The number
travels with the sentence in both bases, because a reader searching for it finds the broker's own
documentation.
| Code | What Studio says |
|---|---|
| 0x01 | the broker does not speak MQTT 5.0, which this provider requires: a subscription's reason code and a message's remaining lifetime exist only in 5.0 |
| 0x81 | the broker could not read the CONNECT packet |
| 0x82 | the broker rejected the CONNECT as not allowed on this connection |
| 0x83 | the broker hit an error of its own that it does not describe further |
| 0x84 | the broker does not support MQTT 5.0, which this provider requires |
| 0x85 | the broker refused the client id |
| 0x86 | the username or password is wrong |
| 0x87 | this account is not authorized to connect |
| 0x88 | the broker is unavailable |
| 0x89 | the broker is busy |
| 0x8a | this client is banned |
| 0x8c | the broker asked for an authentication method this provider does not offer |
| 0x97 | the broker's connection quota is full |
| 0x9f | the broker's connection rate limit was reached |
The codes are Table 3-1's. 0x01 is the exception and is deliberate: it is not a 5.0 code at all, but a
3.1.1 broker answers the older CONNACK whose return code 1 means "unacceptable protocol version", and that is
the broker this provider's version policy meets most often.
A code outside the list is reported as itself in both bases rather than guessed at. No sentence echoes a username or a password.
Every page carries the reason its read ended, as a sentence rather than a token.
Each cell below is endReasonSentence()'s own string, verbatim, trailing full stop included. The panel reads
it as the tail of a longer sentence, which is why each begins in lower case.
| Reason | The sentence |
|---|---|
budget-time |
the read reached its time budget. Reading again, or narrowing the topic filter, sees more. |
budget-messages |
the read reached its message budget. A narrower topic filter sees more of what matters. |
budget-bytes |
the read reached its byte budget. A narrower topic filter sees more of what matters. |
server-disconnect |
the broker closed the connection before the budget ran out, so the list is short of what the filter matched. |
subscription-refused |
The sentence for the broker's own reason code, in the table below. |
transport-closed |
the connection closed before the budget ran out, so the list is short of what the filter matched. |
handshake |
the session only checked that the broker accepts this connection and subscribed to nothing, so it read no topic. |
subscription-refused names the remedy that fits the broker's reason code, because the remedies differ: a
stock EMQX refuses # and grants sensors/# in the same breath, while a broker with no wildcard support
refuses every narrower wildcard too. Each sentence ends with the code in both bases.
| Code | The sentence |
|---|---|
| 0x87 | the broker refused the subscription, so nothing could be read under this filter. Brokers commonly refuse the widest filters and grant narrower ones: try a branch, such as sensors/# instead of # |
| 0x83 | the broker refused the subscription with an error of its own that it does not describe further |
| 0x8f | the broker read the topic filter as invalid. A filter is levels separated by /, with + for one level and # only as the last level: check the filter rather than narrowing it |
| 0x97 | the broker's subscription quota is full, so it accepted no new subscription. This is a limit on the broker rather than anything about the filter |
| 0x9e | the broker does not support shared subscriptions |
| 0xa2 | the broker does not support wildcard subscriptions at all, so no filter carrying + or # can be used on it, however narrow. Typing one exact topic name reads that topic |
| Any other | the broker refused the subscription, so nothing could be read under this filter |
A server that accepts the TCP connection and then says nothing is the failure that usually hangs a client. Three separate clocks make it impossible here: the dial itself is timed, the read carries its time budget, and a read that reaches the end with no CONNACK fails rather than resolving an empty tree. An empty tree would be the wrong answer, not an empty one.
A broker that hangs up before answering is reported as having closed the connection without answering the handshake, and one that sends a DISCONNECT first has its reason code named. No cause is guessed: a TLS-only port does not close a plain connection this way, because its TLS alert arrives as bytes and the read ends in the decoder instead.
Nothing in CI contacts a broker. The provider's transport is a seam, so every test drives it with bytes: hand-encoded packets where the point is a packet our encoder cannot produce, and four recorded broker captures where the point is a real broker's wire.
The structural claims are asserted structurally. The valve is handed a PUBLISH directly rather than being
observed not to send one; the write sweep counts .write( call sites across the whole provider directory; the
decoder's import specifier is pinned, and the package root is loaded alongside it as the control.
bun tests/run-tests.ts tests/unit/db/mqtt/ tests/integration/db/mqtt-provider.test.ts| File | What it holds |
|---|---|
tests/unit/db/mqtt/valve.test.ts |
The one-way valve, inbound and outbound, and the Variable Byte Integer |
tests/unit/db/mqtt/outbound.test.ts |
What reaches the wire: the CONNECT field by field, the SUBSCRIBE, and the decoder's specifier |
tests/unit/db/mqtt/session.test.ts |
The client id, the CONNACK sentences, the three budgets, and the payload the tree does not hold |
tests/unit/db/mqtt/inbound-limit.test.ts |
The 8 MiB skip, including a body spanning chunks and the heap it does not grow |
tests/unit/db/mqtt/key-scan.test.ts |
The page, its truncation report and every end-reason sentence |
tests/unit/db/mqtt/provider.test.ts |
The declarations, the refusals, and the dial against a loopback server |
tests/unit/db/mqtt/egress.test.ts |
Where the dial may connect: link-local and metadata addresses, the flag, and a tunnel |
tests/unit/db/mqtt/session-stats.test.ts |
What a read counts besides the tree, the handshake-only session, and each failure's kind |
tests/unit/db/mqtt/agent-statement.test.ts |
Agent mode's closed table of two reads, and every request refused before the network |
tests/unit/db/mqtt/agent-provider.test.ts |
The agent path through the provider: full packets, answers, budgets, failures and every surface |
tests/unit/db/mqtt/agent-seam.test.ts |
That the agent path can send no PUBLISH and no Will |
tests/integration/db/mqtt-provider.test.ts |
The four captures, replayed |
tests/unit/key-browser-line-contrast.test.ts |
The panel's text colours, as contrast ratios |
e2e/mqtt-provider.spec.ts |
The connection dialog in a browser |
tests/fixtures/mqtt/captures/ holds four recordings, each the bytes one broker sent:
| Capture | Why it exists |
|---|---|
mosquitto-2.1.2.json |
The ordinary case |
hivemq-ce.json |
A second implementation's framing of the same thing |
nanomq.json |
A topic beginning with /, whose first level is empty and legal |
mosquitto-rap.json |
Retain As Published: both messages arrive with the retain bit set, including the one published live |
Each replay asserts the topics by name and the type of each, so a decoder that recorded nothing fails here.
- No query console. MQTT has no query language, and the Keys panel needs no query text.
- Agent mode is metadata only: it counts a window's messages and reads one named topic's metadata, and never names
a topic or reads a payload or
$SYS(section 14). - No WebSocket transport. A broker reached over
ws://orwss://cannot be connected to in this version; plain TCP and TLS are the two transports. - No vendor list APIs. EMQX exposes a real retained count over its management HTTP API; using it would need an admin account, it is not MQTT, and it does not exist on every broker. Candidate for a later version.
$SYSis not subscribed to by default, for the reasons in 3.8.- The Keys panel cannot ask for a second page, by the choice in 3.7.
MQTT_MIGRATION_DDL is what the schema-diff migration generator writes for mqtt, read through migrationDdlFor in src/lib/db/migration-ddl/.
It declines the whole diff: refusal is the one line the migration prints after its header, in place of any DDL, and transactionWrapper is null.
A broker keeps no schema and no catalog, so there is no table DDL to decline or to wrap (#1635).
The refusal reason it prints, verbatim:
- "A broker keeps no schema and no catalog, so there is no column definition to change."
- OASIS, MQTT Version 5.0, OASIS Standard, 7 March 2019. The clauses cited above:
- [MQTT-3.1.2-25], section 3.1.2.11.4: a server MUST discard a packet too large to send
- [MQTT-3.3.2-6], section 3.3.2.3.3: Message Expiry Interval is decremented by the time waited
- [MQTT-4.7.2-1], section 4.7.2: a wildcard never matches a topic beginning
$ - Section 3.1.4, Session Taken Over; section 4.3, the QoS acknowledgement flows; section 1.5.5, the Variable Byte Integer
- Mosquitto's reference client, v2.1.2,
client/client_shared.c:mosquitto_sub -Ibuilds its id assnprintf(cfg->id, ..., "%s%d", cfg->id_prefix, getpid()). Cited in 4.2 by the code it contains rather than by a line number, which moves between releases. - The design: issue #1635, "Read-only MQTT provider".
Agent mode reads a broker through queryReadOnly in index.ts.
The request reader is agent-statement.ts, the read, its bounds and its answers are agent-read.ts, and every other surface of an agent-mode handle is agent-surface.ts.
Every claim below about a broker cites the capture tests/fixtures/mqtt/mosquitto-2.1.2/agent-boundary/boundary.json, recorded by its run.sh against Mosquitto 2.1.2 with authentication on (a user that may only read and one that may read and write) and a stock EMQX 5.8.9, both pinned by digest, and written by tests/live/mqtt-agent-boundary.ts.
The credentials are made for the run; the capture holds none, not even a user name, and no topic name or payload the fixture seeded, and the script checks all three before it writes.
The boundary is the structural closed table of section 3.1 and section 3.2: the encoder holds CONNECT, SUBSCRIBE, UNSUBSCRIBE, PINGREQ and DISCONNECT and no function that writes a PUBLISH, CONNECT takes no Will, and the valve checks every outbound byte again before it reaches the socket.
An agent read reaches the wire only through the session's readTopics, which writes through that valve; the agent's modules write nothing themselves and import neither the encoder nor the valve, which agent-seam.test.ts pins, together with every packet each admitted and refused request writes: CONNECT with Clean Start and no Will bits, one SUBSCRIBE, DISCONNECT, and nothing else.
No credential can be the boundary.
Measured raw (rawPublish, sent by the capture script with the broker's own client and never by Studio): a retained PUBLISH with the agent's credentials, which the broker's ACL lets read only, changed nothing, and the same PUBLISH with the writer's credentials changed the retained set, so a broker with a writable credential, or with no ACL, takes whatever its client sends.
Every session the agent opened wrote Clean Start with a zero Session Expiry Interval and no Will (connect, reads[].sent), and the broker's retained set read the same before and after the agent reads (state).
One JSON object, with the keys read and, for the topic read, topic, each written once:
| Request | What it sends | What it answers |
|---|---|---|
{"read": "window"} |
one SUBSCRIBE to #, for one window |
one row of totals: topics_seen, messages, retained_messages, payload_bytes, messages_lost |
{"read": "topic", "topic": "plant/line1/temp"} |
one SUBSCRIBE to that topic, for one window | one row for that topic: seen, messages, retained, delivered_qos, payload_bytes, last_seen_ms |
Both rows open with the window's own columns: window_ms (how long the window ran, from when the connection was ready to the end of the read: 3,000 ms unless a budget or the broker ended it sooner), listened_ms (how long the subscription listened, from the SUBSCRIBE to the end of the read; the handshake takes its share of the window, so listened_ms is never more than window_ms), stop_reason (an end reason of section 10.2), scope (unknown or incomplete, never complete) and broker_reason_code where the broker refused the subscription.
A topic not seen answers seen: false and null figures, never 0 or false.
retained, delivered_qos and payload_bytes are the last message's, while messages counts the window, and every topic answer says so, because a broker delivers a retained message only as the subscription starts and every later message arrives with the retained flag clear, so retained: false does not say the broker holds no retained message on the topic.
last_seen_ms is counted from the SUBSCRIBE, as listened_ms is.
All three are timed on the monotonic clock the window's timer runs on, never the wall clock, so a wall clock that time sync steps or slews mid-read does not shorten them.
delivered_qos is the QoS the broker delivered at, which the subscription's maximum of 0 (section 3.5) holds at 0 on a conforming broker whatever the publisher sent.
Measured (reads): the window over the fixture's four retained topics and one live publisher counted 18 messages on 5 topics, 4 of them retained; the retained topic read answered seen: true, retained: true, and the live one 15 messages, retained: false; a topic nobody published to answered seen: false; and no answer carried a topic name, a payload or a credential.
Everything else is refused before anything is sent, in agent mode's words with the list of reads and "Nothing was sent.", decided by the request's text alone (refusedBeforeSend, each opening no session): a publish, a will, a key other than read and topic (a filter, a QoS, noLocal, retainHandling, a topic alias, a payload, a property), a topic with + or # anywhere, a topic under a $ root, a topic that is not one MQTT string (empty, with a null character, not well-formed text, or over 65,535 bytes), and a request longer than 4,096 characters.
A filter the model writes would let it find topic names one level at a time, which is why the only filters agent mode sends are # itself and the exact topic the request names: measured raw (rawRefused), the agent's own credentials subscribed to homes/+/door named a seeded topic, and a $share/ subscription took nine messages in two seconds that the devices subscribed beside it would not get.
The exact-topic read answers whether that one topic was seen and nothing about any other: a hit and a miss differ only in that topic's own figures, which agent-provider.test.ts pins.
The answer does not echo the topic the request named, as S3's and Firestore's agent answers do not.
A time budget too short for a whole window and the shortest dial (4,000 ms) is refused rather than shortened; the dial gets the budget less the window and a 250 ms margin to answer in, up to 10 seconds, so 750 ms at the 4,000 ms minimum.
A window ended by Studio's message or byte budget (section 5) is answered as the window it was, with that stop reason: a total over a window is not a cut figure, it is the window.
A result past the byte budget is refused.
A failure is worded by its kind (a refused handshake by Studio's own sentence for the CONNACK code, a handshake that never finished, a broker that could not be reached, an answer that is not MQTT 5.0, the time budget), never in the broker's or the transport's words, and never with a credential.
Topic names are stored values: a topic path names the people and devices behind it, as an S3 key or an Oxia key does.
So are payloads, user properties, correlation data, response topics and content types.
None of them reaches agent mode through a row, a column name, a warning, an error, the grounding or Operate; a loss the read proves (section 3.3) is counted in messages_lost and never named, since the editor's sentence for it names the topic.
$SYS is withheld too, and the capture is why (sys): Mosquitto's $SYS tree carried no client's id, user name or address, but EMQX's carried the connecting client's id in its topic names and its id, user name and address in the payload, so a $ topic is refused by name on every broker rather than answered on one and leaking on another.
Every answer carries the window's own account (how long it listened to what, why it stopped and what it counted), on a topic read the sentence that its retained flag, delivered QoS and payload size are the last message's, a count of proven losses where there were any, and the completeness sentence: "A topic not seen in the window may still exist: MQTT has no command that lists topics, so a subscriber sees a topic only when a message on it arrives in the window or the broker holds a retained message on it, and no answer is a complete count of the broker's topics or proof that a topic does not exist."
| Path | Handle | What it reads |
|---|---|---|
| Investigation reads | agent read-only profile | one fresh session per read of section 14.2 |
| Connect | either agent profile | a handshake that subscribes to nothing: CONNECT and DISCONNECT (connect) |
| Investigation grounding and plan-mode grounding | either agent profile | nothing: the provider declares no kind the walk lists, so the run is told why no topic is listed and how to read one the question names |
| Keys panel, a topic's listing, description and Source tab, the console | either agent profile | nothing: each is refused with its reason |
| Overview, health, performance, slow-query, session, table, index and storage readings | either agent profile | nothing: each answers its reason, never Studio's own figure or an empty reading |
On a stock EMQX the window read meets the broker's own refusal of # and answers subscription-refused with reason code 135 and nothing read, while a topic read is granted (emqx); the answer says which.
Statement-language family: mqttread.
Overlay: src/lib/agent/languages/mqttread/engines/mqtt.ts, with its client row in src/lib/agent/languages/mqttread/engines/client.ts.
The agent reads it through runServerPack and agentEnginePack.
Both overlays are empty, so the engine resolves to the mqttread family pack itself.
The mqttread statement-language family has no dialect-bound member.
This overlay overrides no Shared member, so every Shared member is the mqttread statement-language family's own.
The family offers the investigation workflow, through run_read_query with one read request, and the operations workflow; it offers no query optimization or database assessment, since a broker has no plan and no profile, and no data analysis, whose tools need stored values, and no workflow hands an answer to the editor.
The investigation rule tells the model to read a topic the question names directly, to read the window only for the broker's totals, never to guess a topic, and to report a topic not seen as not seen in that window, never as absent.
A plan of the investigation is prose: an MQTT connection has no query console, so there is no statement for the editor to run, and plan mode is ungrounded on it, as agent mode is.
The editor language id stays mqtt, and the fence alias mqttread names this engine.
A pack does not open agent mode: execution is gated by AGENT_EXECUTION_ENGINES and the execution-profile check.
MCP clients run an MQTT read with run_mqtt_read, the tool the family declares in its MCP facet (src/lib/agent/languages/mqttread/mcp.ts), opened by MCP_EXPOSABLE.mqtt and MCP_STATEMENT_READ.mqtt (section 15); READ_ONLY_ENFORCED.mqtt is unchanged.
run_mqtt_read takes one read request as statement and runs it through queryReadOnly under the agent read-only profile, so every rule of section 14 holds for it unchanged: the closed five-packet encoder and the valve, one fresh session per read, and the metadata-only projection.
The agent's own reader is the tool's fence, so a request outside the table is refused before anything is acquired, in the sentence an agent run reads; with that fence removed the provider's reader refuses it before anything is sent past the connection's handshake (tests/live/mqtt-mcp-boundary.ts).
It takes no offset and no database: every read answers one row.
list_connections lists an MQTT connection without its database, since that setting is the discovery topic filter, a stored value; the facet says so (withholdsDatabase).
Every answer carries notes, the provider's warnings: the window it listened for, what it counted and why it stopped, for a topic read what its per-message columns are, a loss the read proved, counted, and that a topic not seen in the window may still exist.
An MCP answer carries no warning on other tools; these are Studio's own sentences, naming no topic, so this tool's facet passes them on (answersWarnings).
The call that opens the connection opens two broker sessions: the acquisition's handshake, CONNECT and DISCONNECT with no subscription, and the read.
A later call on the same connection reuses that acquisition, which Studio keeps until it has been idle for 30 minutes (IDLE_TIMEOUT_MS in src/lib/db/factory.ts), and opens one session, the read.
Each has a client id of its own, libredb- and 16 random hex digits, so a call never takes over a live client's id; measured on Mosquitto 2.1.2, one call logged two new clients with two ids, and a live client holding its own id stayed connected through every call.
timeout_ms must be at least 5000: the read needs 4,000 ms, its 3,000 ms window and the least dial, and the call leaves the handshake 1,000 ms before that; a smaller one is refused before anything is acquired, and a handshake slower than its allowance leaves the read too little, which the provider refuses before the read is sent.
A broker that accepts the TCP connection and never sends a CONNACK is given up at about 3 s whatever timeout_ms is, when the handshake's own 3,000 ms window ends (MQTT_READ_BUDGET_MS), and the call is answered as an engine error saying no CONNACK arrived, not as the timeout answer.
A cancel or a timeout ends the wait: the read's session runs on until its own window ends and leaves, and its answer is dropped (measured: a cancel at 1,000 ms answered at 1,001 ms, and the read ended on its window at 3,002 ms).
The principal is the seed's agent credential when it sets one, and the seed's own login otherwise, and no broker tells a client what its ACL grants, so Studio proves nothing about it.
Give an MQTT seed with mcp: true a login whose ACL grants read alone on the topics an MCP client should count.
What a login reaches, in two layers, measured on Mosquitto 2.1.2 with an ACL file and on a stock EMQX 5.8.9 (tests/fixtures/mqtt/mosquitto-2.1.2/mcp-boundary/boundary.json):
| Item | The broker's ACL | Studio |
|---|---|---|
the window, under a login reading # |
the subscription granted, every topic delivered | totals only: topics seen, messages, retained messages, payload bytes; never a topic name |
the window, under a login reading factory/# alone |
the subscription to # granted, and only the login's own topics delivered |
the totals of what was delivered, so they count the login's topics alone |
| a topic outside that login's ACL, holding a retained message, and one that does not exist | granted and nothing delivered, alike | seen false, every figure null, the same answer for both |
| an idle topic and a missing one, under a broad login | nothing delivered, alike | the same answer for both |
| the window on a stock EMQX | the subscription to # refused with reason code 135 by its default ACL |
stop_reason subscription-refused, broker_reason_code 135, nothing read |
$SYS, $share/ and every filter but # at the root |
never sent | refused before anything is acquired |
So the topic scope is held by the broker's ACL, which does not depend on parsing the request, and Studio's projection, computed on the answer, names no topic whatever the request named; a topic outside the login's ACL is not told from a missing one. A broker that lets the seed's login read every topic is counted whole, topic names still withheld.