This extension transports trace spans from an MCP provider to an observability backend without coupling firmware to an OpenTelemetry SDK. It is optional on both sides:
The current signal is distributed tracing. The wire envelope is generic and versioned so metrics or logs can be added later without overloading trace semantics.
| Layer | Responsibility |
|---|---|
MCP service, such as McpModbusService |
Decides when tracing is enabled, creates IDs, names spans, records domain attributes and optional PDU events |
libmcpb |
Encodes the notification into a caller-owned buffer and transports the bytes, with no allocation |
mcp-broker |
Validates, bounds, queues, enriches with the slot name and dispatches batches |
| OTLP exporter | Converts the compact span to OTLP/HTTP JSON and sends it to a collector |
| OpenTelemetry Collector | Routes, samples, redacts and exports traces to the deployment backend |
The broker does not inspect Modbus, store PDUs, or decide whether a device may be traced. Those policies belong to the service that owns the protocol and its data sensitivity.
One W3C trace ID follows the operation across the whole service:
MCP client
|
v
mcp-scada SERVER span
|
v
mcp-scada CLIENT span
|
v
mcp-modbus SERVER span
|
v
Modbus TCP or RTU CLIENT span
+-- event modbus.pdu.tx
+-- event modbus.pdu.rx
|
v
broker/telemetry
|
v
mcp-broker bounded queue
|
v
OTLP/HTTP collector
The MCP SERVER span and protocol CLIENT span should be distinct. This keeps the
service boundary visible while PDU transmit and receive remain events rather
than extra spans. Both forms remain one trace because traceId is unchanged.
Every addressed MCP request routed by the broker carries a W3C version 00
trace context in params._meta.traceparent:
{
"params": {
"_meta": {
"traceparent": "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01"
}
}
}
The broker preserves a valid context supplied by the caller. If it is absent or
malformed, the broker creates a sampled root context. A receiving provider uses
the trace id and parent id for its SERVER span. Before an intermediary calls
another slot, it replaces the parent id with its CLIENT span id. The provider
package exports traceparentOf(), withTraceparent(),
childTraceparent(), parseTraceparent() and formatTraceparent().
tracestate and baggage are not interpreted or generated by this extension.
They need a separate size and data-sensitivity policy before provider helpers
can treat them as part of the trace contract.
The audit correlationId and W3C traceId remain distinct. A caller reference
contains both, and each broker/authorize audit event records both. A provider
should attach returned authorization decision ids as
mcp.authz.decision_id span attributes.
The service must retain the request trace context until the response is validated. This matches the Modbus PDU lifetime: the request buffer may be returned to its pool after validation, while the trace record retains only the selected metadata or copied bytes allowed by policy.
Providers send one JSON-RPC notification per completed span:
{
"jsonrpc": "2.0",
"method": "broker/telemetry",
"params": {
"version": 1,
"signal": "traces",
"span": {
"traceId": "0123456789abcdef0123456789abcdef",
"spanId": "0123456789abcdef",
"parentSpanId": "fedcba9876543210",
"name": "modbus.read_input_registers",
"kind": 3,
"startTimeUnixNano": "1720000000000000000",
"endTimeUnixNano": "1720000000001000000",
"attributes": {
"rpc.system": "mcp",
"rpc.method": "tools/call",
"modbus.transport": "tcp",
"modbus.unit_id": 1,
"modbus.function_code": 4,
"modbus.address": 0,
"modbus.quantity": 2
},
"events": [
{
"name": "modbus.pdu.tx",
"timeUnixNano": "1720000000000100000",
"attributes": { "network.io.bytes": 5 }
},
{
"name": "modbus.pdu.rx",
"timeUnixNano": "1720000000000900000",
"attributes": { "network.io.bytes": 6 }
}
],
"status": { "code": 1 }
}
}
}
Validation rules:
traceId is 32 lowercase hexadecimal characters and is not all zero;spanId and optional parentSpanId are 16 lowercase hexadecimal characters
and are not all zero;kind uses the OpenTelemetry values 0 through 5;status.code uses 0 unset, 1 OK, or 2 error;An invalid notification is dropped and counted. It is never passed through to clients as an ordinary MCP notification.
broker/telemetry is a provider-to-broker notification and never receives a
JSON-RPC response. The broker/* namespace keeps this extension separate from
the MCP specification’s notifications/* methods.
TypeScript providers can validate and emit the notification without rebuilding the envelope:
transport.broker.span(span);
The helper returns false and drops the span while the provider link is down.
It never queues telemetry ahead of MCP requests or responses.
libmcpb exposes one allocation-free codec:
#include "mcpb/mcpb_telemetry.h"
char frame[768];
int length = mcpb_telemetry_encode(span_json, span_json_length,
frame, sizeof(frame));
if (length >= 0)
mcpb_provider_send(&provider, frame, (size_t)length);
The caller owns span_json and frame. The codec copies the span verbatim,
adds the versioned JSON-RPC envelope and a trailing NUL, and returns
MCPB_ERR_TOO_LARGE when the buffer cannot contain the result. A multiplexed
gateway sends the same encoded frame with mcpb_mux_send on the chosen slot.
mcpb_traceparent_parse() and mcpb_traceparent_format() validate and encode
the fixed-size W3C context without allocation. Random trace and span IDs remain
the application’s responsibility through its platform random source.
No queue exists in libmcpb. A disconnected link or a full application trace
buffer drops telemetry according to the firmware policy. Control responses
must always take priority over observability traffic.
Default limits:
| Limit | Default |
|---|---|
| notification size | 65536 bytes |
| queued spans | 256 |
| exporter batch | 32 spans |
| attributes per span or event | 64 |
| events per span | 32 |
The provider receive path only validates and enqueues. Export runs asynchronously. When the queue is full, the new span is dropped. Failed export batches are not retried inside the broker because an unavailable observability backend must not grow memory use or delay machine control.
WsTunnel.getTelemetryStats() returns:
{
enabled,
accepted,
exported,
droppedInvalid,
droppedOversize,
droppedQueueFull,
droppedExporter,
exportErrors,
queued
}
Use these counters for broker Health. Use the provider’s own counters for MCU Health so a lost trace before transport is visible too.
broker_diagnose includes the same telemetry snapshot. It reports a warning
when exporter batches fail or when the bounded queue drops new spans.
The CLI accepts standard OpenTelemetry variables:
$env:OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = "http://collector:4318/v1/traces"
$env:OTEL_EXPORTER_OTLP_HEADERS = "Authorization=Bearer%20token"
mcp-broker
Equivalent config.json:
{
"telemetry": {
"otlpHttpEndpoint": "http://collector:4318/v1/traces",
"timeoutMs": 5000,
"serviceNamespace": "factory-gateway",
"maxFrameBytes": 65536,
"queueCapacity": 256,
"batchSize": 32,
"maxAttributes": 64,
"maxEvents": 32
}
}
Programmatic setup:
const tunnel = new WsTunnelBuilder()
.withOtlpHttpTelemetry(
{ endpoint: "http://collector:4318/v1/traces" },
{ queueCapacity: 512, batchSize: 32 }
)
.build();
withTelemetry({ exporter }) accepts a custom bounded destination adapter when
the host already owns an OpenTelemetry SDK, a file sink, Kafka, or another
transport.
Tests can enable the in-memory exporter without a collector:
const broker = await startTestBroker({ telemetry: true });
// drive the provider, then inspect an immutable snapshot
expect(broker.spans).toHaveLength(1);
Minimal OpenTelemetry Collector configuration:
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
processors:
batch: {}
exporters:
debug:
verbosity: detailed
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [debug]
For an authenticated provider, the broker sets service.name to its provider
principal and adds mcp.provider.principal. For an anonymous provider,
service.name falls back to the slot. mcp.provider.slot and
service.instance.id always retain the slot, so one principal publishing
multiple slots remains one logical service with distinguishable instances.
service.namespace defaults to mcp-broker.provider unless configured.
Integral numeric attributes use OTLP JSON intValue with a decimal string.
Non-integral numbers use doubleValue.
Packet metadata is safe enough for normal tracing. Raw PDU bytes can contain register values, commands, identifiers or production data. They are disabled by convention and should only be attached while an authorized runtime trace session explicitly requests packet capture.
When raw bytes are enabled:
The recommended representation is a hexadecimal string event attribute such
as modbus.pdu.hex, not a new wire field. This keeps the trace schema generic
and lets collector processors remove the attribute before external export.
For SCADA spans, the normal attribute policy is deliberately narrow:
The broker enforces structural and size limits. The SCADA and Modbus providers enforce this semantic data policy because the broker intentionally does not understand their protocols.