mcp-usage-export 0.5.0

Buffered, failure-isolated usage exporters for UsageKit for MCP.
Documentation

mcp-usage-export

Failure-isolated billing exporters for MCP usage. The pipeline records usage synchronously, aggregates concurrent events, and preserves quantities and provider event identifiers across retries. Export failure never changes an MCP response.

MeterEventExporter connects the pipeline to an application-supplied MeterEventProvider without adding an HTTP client or provider SDK. The provider receives only unresolved aggregates in original order and returns one ordered outcome per aggregate. Confirmed events are skipped on retry, while ambiguous and retryable events retain their stable identifiers. Permanent and verified asynchronous rejections use a bounded, identifier-deduplicated dead letter queue that applications can drain for reconciliation.

Provider implementations own transport, authentication, validation, timestamp rules, and wire encoding. Outcome and batch-error codes must be static, low-cardinality categories containing no secrets or customer data.

Provider implementation example

The following provider-shaped example shows the boundary an adapter owns. The payload fields are deliberately generic, but they cover the stable meter identity, customer, quantity, meter, and timestamp data that a hosted billing API might require. Replace send with the application's authenticated transport and map its responses to the ordered outcomes.

use mcp_usage_export::{
    AggregatedUsage, MeterEventOutcome, MeterEventProvider, MeterEventProviderError,
    MeterEventProviderFuture,
};

#[derive(Debug)]
struct ExampleMeterProvider;

#[derive(Debug)]
struct ExampleMeterPayload {
    identifier: String,
    customer_id: String,
    meter: String,
    units: u64,
    timestamp: u64,
}

impl ExampleMeterProvider {
    async fn send(
        &self,
        payloads: Vec<ExampleMeterPayload>,
    ) -> Result<Vec<MeterEventOutcome>, MeterEventProviderError> {
        // Send payloads through the application's provider client here.
        // Preserve payload order when translating provider responses.
        Ok(payloads
            .into_iter()
            .map(|payload| {
                let _ = payload;
                MeterEventOutcome::Accepted
            })
            .collect())
    }
}

impl MeterEventProvider for ExampleMeterProvider {
    fn submit<'a>(&'a self, batch: &'a [AggregatedUsage]) -> MeterEventProviderFuture<'a> {
        Box::pin(async move {
            let payloads = batch
                .iter()
                .map(|usage| ExampleMeterPayload {
                    identifier: usage.identifier.clone(),
                    customer_id: usage.customer_id.clone(),
                    meter: usage.meter.clone(),
                    units: usage.units,
                    timestamp: usage.timestamp,
                })
                .collect();
            self.send(payloads).await
        })
    }
}

The real send implementation must return one outcome for every payload. Map temporary transport or provider availability failures to RetryableFailure { code: "unavailable" }, event-specific permanent failures to PermanentRejection { code: "invalid_event" }, and batch-wide failures to a static MeterEventProviderError such as "unavailable". Keep provider-side idempotency keyed by AggregatedUsage::identifier across ambiguous retries.

Wrap the provider in the normal pipeline:

use mcp_usage_export::{BillingPipeline, MeterEventExporter};

let billing = BillingPipeline::new(MeterEventExporter::new(ExampleMeterProvider));

API documentation | Project site

Licensed under Apache-2.0.