MLLP Server

Features

Receive HL7 messages, validate them before routing, persist to an outbox, and deliver to downstream systems with retries.

One binary. Receives HL7 v2 over MLLP, validates with CEL, routes to connectors, persists to disk. No message broker. No JVM. No runtime dependencies.


MLLP Protocol

Standard MLLP framing per the HL7 specification (Appendix C): 0x0B (Start Block) + payload + 0x1C 0x0D (End Block + Carriage Return). Leading garbage bytes before the start block are tolerated, as allowed by the spec.

ParameterDefaultEnv var
Max frame size2 MBMAX_FRAME_SIZE
Idle timeout30sIDLE_TIMEOUT
Frame timeout60sFRAME_TIMEOUT
Write timeout5sWRITE_TIMEOUT
Keep-alive period60sKEEP_ALIVE_PERIOD
Max connectionsunlimitedMAX_CONNECTIONS

The idle and frame timeouts work together — whichever is shorter applies on each read. This protects against slow-loris attacks without breaking large message transfers. On shutdown, in-flight messages complete before the process exits.


CEL Validation

Rules are written in Common Expression Language and compiled at startup. A bad rule fails the startup, not a message at 3am.

Rules are evaluated in order. First failure short-circuits. The result maps to an HL7 ACK code:

ResultACK codeMeaning
All rules passAAAccept — message routed to connectors
Rule returns falseARReject — permanent, do not retry
Evaluation errorAEError — transient, sender may retry

Available fields

Every rule can access parsed fields from four HL7 segments:

VariableSegmentFields
mshMSHmsg_type, trigger, sending_app, sending_fac, receiving_app, receiving_fac, control_id, version
pidPIDid (PID-3.1), name (PID-5), dob (PID-7), sex (PID-8), ssn (PID-19), country (PID-11.6)
pv1PV1patient_class (PV1-2), assigned_location (PV1-3), attending_doctor (PV1-7), admit_datetime (PV1-44)
obxOBXFirst OBX only: value_type, identifier, value, unit, status
obx_listOBXAll OBX segments as a list, same fields as obx

Missing segments return empty maps. Missing lists return empty lists. No null errors in expressions.

Example rules

validation:
  rules:
    - name: require-patient-id
      expression: pid['id'] != ""
      message: "PID-3.1 is required"

    - name: require-sending-facility
      expression: msh['sending_fac'] != ""
      message: "MSH-4 sending facility is required"

    - name: finnish-patients-only
      expression: pid[?'country'].orValue("") == "FI"
      message: "Only Finnish patients accepted at this endpoint"

    - name: all-observations-final
      expression: obx_list.all(o, o['status'] == "F")
      message: "All OBX segments must have final status"

The ? operator provides safe key access — no error if the key is absent.


TLS and mTLS

Set TLS_CERT_FILE and TLS_KEY_FILE to enable TLS. Add TLS_CLIENT_CA to require and verify client certificates (mTLS).

Certificate hot-reload

Send SIGHUP to reload certificates from disk. No restart, no dropped connections. New connections use the updated certificate immediately. Existing connections continue on the previous one until they close naturally.

Cipher suites

TLS 1.3 uses safe defaults (not configurable — the runtime handles it). For TLS 1.2, the server enforces AEAD-only cipher suites:

Cipher suite
TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305
TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305
TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256

All suites use ECDHE key exchange. No static RSA. No CBC.

Handshake timeout

Stalled TLS handshakes are dropped after CONNECT_TIMEOUT (default 10s). Prevents resource exhaustion from clients that connect but never complete the handshake.

VariableDefaultDescription
TLS_CERT_FILEPath to PEM certificate
TLS_KEY_FILEPath to PEM private key
TLS_CLIENT_CACA file for mTLS client verification
TLS_MIN_VERSION1.2Minimum TLS version (1.2 or 1.3)
CONNECT_TIMEOUT10sTLS handshake timeout

Connectors

Connectors deliver accepted messages to downstream systems. Each connector has its own delivery queue — connector latency does not affect MLLP throughput. The server ACKs the sender before any connector sees the message.

CEL-based routing

Each connector has an optional filter expression. Same CEL variables as validation. Connectors without a filter receive every message. Multiple connectors can match a single message — fan-out is atomic.

connectors:
  - name: adt-processor
    type: http
    url: https://adt.example.com/hl7
    filter: msh.msg_type == "ADT"

  - name: all-messages-archive
    type: http
    url: https://archive.example.com/hl7
    # no filter — receives everything

Retry with exponential backoff

Failed deliveries retry with exponential backoff and jitter:

ParameterDefault
Initial delay1s
Max delay5m
Max attempts5
Jitter0–25%

After max_attempts failures, the message moves to the dead letter queue. Set dead_letter.disabled: true to retry indefinitely.

Environment variable interpolation

Connector config supports ${VAR_NAME} syntax. All referenced variables must be present at startup — a missing variable is a fatal error, not a silent empty string.

connectors:
  - name: primary
    type: http
    url: ${DOWNSTREAM_URL}
    headers:
      Authorization: "Bearer ${API_TOKEN}"

Message Persistence

Every accepted message is written to an embedded database before the ACK is sent. The outbox survives process restarts. No external database required.

Each connector gets two queues: an outbox for pending deliveries and a DLQ for messages that exhausted retries.

When a message matches multiple connectors, all writes happen in a single transaction. Either every connector gets the message or none do. No partial delivery on crash.

Delivery order is FIFO per connector. Messages are delivered in the order they arrived.


Observability

Metrics

Set OTEL_EXPORTER_OTLP_ENDPOINT to export metrics via OpenTelemetry (OTLP over gRPC, every 10 seconds). When unset, metrics are disabled — no overhead, no infrastructure required.

MetricTypeDescription
mllp.connections.activeUpDownCounterCurrent open connections
mllp.connections.totalCounterTotal connections accepted
mllp.messages.processedCounterMessages processed (by ack_code)
mllp.message.durationHistogramProcessing latency in seconds (by ack_code)
mllp.bytes.receivedCounterBytes received from clients
mllp.bytes.sentCounterBytes sent (ACKs)

Logging

Operational logs are structured JSON on stdout. Each line carries version, instance_id, and hostname. Connection-scoped logs add remote_ip, connection_id, and tls_version. Message-scoped logs add message_control_id, message_type, and sending_facility.

Log writes are buffered — a slow disk does not block message processing.

Audit trail

Audit events (message_received, ack_sent, nak_sent) are written synchronously to stderr. Separate from operational logs so they can be routed to a SIEM independently. Synchronous writes guarantee ordering and durability.

Log rotation

Send SIGHUP to reopen the log file. Compatible with logrotate.

VariableDefaultDescription
LOG_LEVELinfodebug, info, warn, error
LOG_OUTPUTstdoutstdout, file, console
LOG_FILE_PATH/var/log/mllp/mllp.logFile path when LOG_OUTPUT=file
LOG_BUFFER_SIZE10000Log buffer capacity (entries)
LOG_POLL_INTERVAL10msBuffer flush interval

Signal Handling

SignalEffect
SIGINT / SIGTERMGraceful shutdown: stop accepting, drain in-flight, exit
SIGHUPReload TLS certificates + rotate log file
SIGUSR1Cycle log level at runtime

A second SIGINT/SIGTERM forces an immediate exit.

PRE_SHUTDOWN_DELAY (default 0) inserts a wait before connection draining begins. Set to 3–10s in Kubernetes so the load balancer removes the pod from rotation before the server stops accepting.

VariableDefaultDescription
SHUTDOWN_TIMEOUT30sMax time to wait for connection drain
PRE_SHUTDOWN_DELAY0Delay before drain (Kubernetes LB propagation)

Configuration

No config file for the server itself. Every setting is an environment variable with a sensible default. Configure via Kubernetes ConfigMaps, Docker --env, or shell exports.

See the Configuration reference for the full list.