mcp-broker

Provider telemetry

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.

Responsibility split

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.

End-to-end correlation

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.

MCP trace context carrier

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.

Provider wire contract

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:

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.

MCU encoding

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.

Broker queue and failure isolation

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.

Configuration

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);

Collector example

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.

PDU data and security

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.