Guides
MLLP over TCP
The Minimal Lower Layer Protocol is three bytes of framing and nothing else. This guide covers what you actually need on top of it: whole messages out of a chopped-up stream, an acknowledgement that names what it answers, and a bound on what a broken peer can allocate.
The whole protocol
A TCP stream is bytes without edges, and an HL7 v2 message carries no length prefix and no self-delimiting syntax — so a receiver reading a socket cannot tell where one message stops and the next begins. MLLP is the answer, and this is all of it:
<VT> message <FS><CR>
0x0B 0x1C 0x0DNo length, no checksum, no session, no negotiation, no encryption. Nothing else in the specification.
Framing one message
use hl7_2_mllp as mllp;
let frame = mllp::encode(message.as_bytes());
assert_eq!(frame[0], mllp::START_BLOCK);
assert_eq!(mllp::decode(&frame)?, message.as_bytes());The payload is copied verbatim — not trimmed, not validated, not normalized. Note that a
message's own \r segment terminators are the same byte as the frame's trailer, and
they survive untouched.
Streaming: the Framer
This is the one a socket actually needs. Frames arrive split across reads, several to a read, or
both, and Framer is the small amount of state that puts them back together.
use hl7_2_mllp::Framer;
let mut framer = Framer::new();
framer.push(b"\x0bMSH|one\x1c\r\x0bMSH|t"); // one and a half messages
framer.push(b"wo\x1c\r"); // the other half
assert_eq!(framer.next_frame()?.unwrap(), b"MSH|one");
assert_eq!(framer.next_frame()?.unwrap(), b"MSH|two");
assert_eq!(framer.next_frame()?, None); // nothing more yetA partial frame is Ok(None), not an error — it means “read more” — and a frame may
be split anywhere, including between <FS> and its <CR>.
// The default cap on what a single frame may buffer is 16 MiB.
let framer = Framer::new().with_limit(4 * 1024 * 1024);
assert_eq!(hl7_2_mllp::DEFAULT_LIMIT, 16 * 1024 * 1024);Transport
use hl7_2_mllp::{IoTransport, Transport};
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:2575")?;
for stream in listener.incoming() {
let mut transport = IoTransport::new(stream?);
while let Some(message) = transport.receive()? {
// ... one whole HL7 message ...
}
}IoTransport works over anything that reads and writes bytes — a TcpStream, a TLS stream, a Unix socket, a Cursor in a test — and the Transport trait is there for carriers it does not know about. send_str is the convenience for messages you hold as text; send bytes when the
message is already encoded in the character set MSH-18 names.
Acknowledgement
MLLP has no acknowledgement of its own. The reply HL7 expects is an HL7 message: an ACK whose MSA-2 echoes the control ID of the message being answered.
use hl7_2_mllp::{AckCode, ack};
let frame = ack::acknowledge(&payload, AckCode::Accept, "ACK00001", "20260814080100")?;
transport.send(hl7_2_mllp::decode(&frame)?)?;That echo is the whole mechanism. MLLP guarantees a message arrived whole; only the echoed control ID says which message arrived — so a sender that does not compare it will eventually take one answer for another's.
When the receiver needs to look before it answers, which is the usual case:
let message = ack::parse(&payload)?;
let mut nack = ack::acknowledge_message(&message, AckCode::Error, "N1", "20260814080100")?;
nack.set("MSA-3", "OBR-4 is required")?;
transport.send(nack.to_er7().as_bytes())?;Every call takes the acknowledgement's own control ID and timestamp as arguments, because a
message that invents them is untestable and untraceable. The clock feature adds acknowledge_now for callers who genuinely just want the current time.
Strictness and tolerance
By default a frame must start with <VT>, end with <FS><CR>, and contain neither block character in between. Real senders
are not always strict.
use hl7_2_mllp::{Framer, Tolerance};
// For that one sender whose stack forgets the trailing <CR>.
let framer = Framer::new().with_tolerance(Tolerance::Lenient);
// The default, whatever the crate features say.
let framer = Framer::new().with_tolerance(Tolerance::Strict);Tolerance::Lenient forgives the two common sins — a missing <CR> after <FS>, and stray bytes between frames — and
nothing else.
Prefer setting it per connection, for the one sender that needs it, over enabling the
crate-wide noncompliance feature. A receiver that quietly accepts malformed framing
is how a truncated message becomes a clinical record, and loosening every connection at once to
accommodate one peer is not a trade worth making.
Features
| Feature | Default | Effect |
|---|---|---|
ack | on | Acknowledgement generation; pulls in hl7-2. |
clock | off | acknowledge_now; pulls in chrono. Implies ack. |
noncompliance | off | The default tolerance becomes lenient. |
--no-default-features gives framing, streaming, and transport with no dependencies at all — useful when the process that terminates the socket is
not the process that understands the message.
What this crate does not do
MLLP is a small protocol and this is a small crate. It has:
- No TLS. Compose it —
IoTransporttakes any stream. - No async runtime.
- No connection pooling, and no retry or reconnect policy.
- No opinion on HL7 v2 semantics — that is
hl7-2.
What a production listener also needs
The crate ships two runnable examples that talk to each other, and the listener is commented with exactly this list:
cargo run --example tcp_listener # accepts, reads, acknowledges
cargo run --example tcp_sender # sends, waits, checks the echo- TLS, unless the link is genuinely private end to end.
- A read timeout, so a half-open connection does not hold a thread forever.
- A connection bound, so a burst of connections does not exhaust the process.
- Persistence before acknowledging — see the warning above. This is the one that costs data when it is missed.
For a step-by-step build of a listener that does all four, see An MLLP listener that answers.