Skip to main content

mcp_usage_tower/
layer.rs

1//! Generic Tower [`Layer`](tower::Layer) implementation.
2
3use std::convert::Infallible;
4use std::error::Error as StdError;
5use std::future::Future;
6use std::pin::Pin;
7use std::sync::Arc;
8use std::task::{Context, Poll};
9use std::time::Duration;
10
11use bytes::{Buf, Bytes, BytesMut};
12use http::header::{AUTHORIZATION, CACHE_CONTROL, CONTENT_TYPE, X_CONTENT_TYPE_OPTIONS};
13use http::{HeaderMap, Request, Response, StatusCode};
14use http_body::{Body, Frame};
15use http_body_util::combinators::UnsyncBoxBody;
16use http_body_util::{BodyExt, Full};
17use mcp_usage_core::{
18    Call, Charge, Method, ResultType, TaskAttribution, TaskStatus, TrustFailure,
19    decide_with_task_attribution, validate_header,
20};
21use mcp_usage_export::{NoopRecorder, RecordOutcome, SharedRecorder, UsageEvent};
22use serde_json::{Value, json};
23use thiserror::Error;
24use tower::{Layer, Service};
25
26use crate::auth::{AuthFailureLimit, Tenant, TenantStore, hash_api_key};
27use crate::cache::{
28    CachedResponse, RequestMetadata, ResponseCache, cache_hints, inspect_request, render_with_id,
29};
30use crate::classify::{ClassificationError, classify_protocol_headers};
31use crate::deferred::DeferredCompletions;
32use crate::metrics::{EdgeMetrics, UnaccountedReason};
33use crate::task::{InMemoryTaskStore, TaskAttributionStore};
34use crate::{METHOD_HEADER, NAME_HEADER, PROTOCOL_VERSION_HEADER};
35
36/// Erased body error returned by [`MeterService`].
37pub type BoxError = Box<dyn StdError + Send + Sync>;
38/// Response body returned by [`MeterService`].
39pub type MeterBody = UnsyncBoxBody<Bytes, BoxError>;
40
41const DEFAULT_CACHE_ENTRIES: usize = 512;
42const DEFAULT_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
43/// Terminal accounting parked for want of somewhere to await.
44const DEFAULT_DEFERRED_CAPACITY: usize = 4_096;
45/// Parked completions run at the start of each request.
46///
47/// Small on purpose. This borrows the request path to make progress, so it has
48/// to stay cheap; an application under load has no shortage of requests to
49/// amortize the backlog across.
50const DEFAULT_DEFERRED_DRAIN_PER_REQUEST: usize = 2;
51
52/// Invalid edge configuration.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
54pub enum EdgeConfigError {
55    /// Authentication failure windows must advance over time.
56    #[error("authentication failure window must be greater than zero")]
57    InvalidAuthFailureWindow,
58}
59
60/// Runtime configuration shared by every cloned metering service.
61#[derive(Clone)]
62pub struct EdgeConfig {
63    tenants: Arc<dyn TenantStore>,
64    recorder: SharedRecorder,
65    tasks: Arc<dyn TaskAttributionStore>,
66    cache: Arc<ResponseCache>,
67    metrics: Arc<EdgeMetrics>,
68    meter_name: String,
69    max_request_body: usize,
70    max_response_capture: usize,
71    cache_max_entries: usize,
72    cache_max_ttl: Duration,
73    share_public_cache: bool,
74    forward_credentials: bool,
75    deferred: Arc<DeferredCompletions>,
76    deferred_drain_per_request: usize,
77    auth_failure_limit: Option<Arc<AuthFailureLimit>>,
78    strict_protocol_version: bool,
79}
80
81impl std::fmt::Debug for EdgeConfig {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        f.debug_struct("EdgeConfig")
84            .field("meter_name", &self.meter_name)
85            .field("max_request_body", &self.max_request_body)
86            .field("max_response_capture", &self.max_response_capture)
87            .field("share_public_cache", &self.share_public_cache)
88            .field("forward_credentials", &self.forward_credentials)
89            .field("strict_protocol_version", &self.strict_protocol_version)
90            .field("deferred_pending", &self.deferred.len())
91            .finish_non_exhaustive()
92    }
93}
94
95impl EdgeConfig {
96    /// Construct an edge using API-key tenants and a no-op billing recorder.
97    #[must_use]
98    pub fn new(tenants: Arc<dyn TenantStore>) -> Self {
99        Self {
100            tenants,
101            recorder: Arc::new(NoopRecorder),
102            tasks: Arc::new(InMemoryTaskStore::new()),
103            cache: Arc::new(ResponseCache::new(
104                DEFAULT_CACHE_ENTRIES,
105                DEFAULT_CACHE_TTL,
106                false,
107            )),
108            metrics: Arc::new(EdgeMetrics::default()),
109            meter_name: "mcp_units".to_owned(),
110            max_request_body: 1024 * 1024,
111            max_response_capture: 1024 * 1024,
112            cache_max_entries: DEFAULT_CACHE_ENTRIES,
113            cache_max_ttl: DEFAULT_CACHE_TTL,
114            share_public_cache: false,
115            forward_credentials: false,
116            deferred: Arc::new(DeferredCompletions::new(DEFAULT_DEFERRED_CAPACITY)),
117            deferred_drain_per_request: DEFAULT_DEFERRED_DRAIN_PER_REQUEST,
118            auth_failure_limit: None,
119            strict_protocol_version: false,
120        }
121    }
122
123    /// Rebuild the cache from every input that shapes it.
124    ///
125    /// The builders below are order-independent because each one records its
126    /// setting and then rebuilds from all of them. Constructing the cache in
127    /// place inside a single builder would let a later call silently discard an
128    /// earlier one.
129    fn rebuild_cache(&mut self) {
130        self.cache = Arc::new(ResponseCache::new(
131            self.cache_max_entries,
132            self.cache_max_ttl,
133            self.share_public_cache,
134        ));
135    }
136
137    /// Refuse requests that predate the mirrored headers instead of reading their body.
138    ///
139    /// By default a legacy request is classified from its JSON-RPC body, which is what
140    /// lets clients older than [`mcp_usage_core::version::HEADER_VALIDATION_SINCE`] -
141    /// still the majority - reach the origin at all. The body is the document the origin
142    /// executes, so pricing on it cannot be gamed the way pricing on unvalidated headers
143    /// can; the cost is one bounded JSON parse per legacy request.
144    ///
145    /// Set this when a deployment would rather serve only modern clients than spend that
146    /// parse, or when an upstream proxy already guarantees the revision.
147    #[must_use]
148    pub fn with_strict_protocol_version(mut self, strict: bool) -> Self {
149        self.strict_protocol_version = strict;
150        self
151    }
152
153    /// Install the hot-path usage recorder.
154    #[must_use]
155    pub fn with_recorder(mut self, recorder: SharedRecorder) -> Self {
156        self.recorder = recorder;
157        self
158    }
159
160    /// Install a durable task-attribution store.
161    #[must_use]
162    pub fn with_task_store(mut self, tasks: Arc<dyn TaskAttributionStore>) -> Self {
163        self.tasks = tasks;
164        self
165    }
166
167    /// Set the provider meter event name.
168    #[must_use]
169    pub fn with_meter_name(mut self, meter_name: impl Into<String>) -> Self {
170        self.meter_name = meter_name.into();
171        self
172    }
173
174    /// Configure cache capacity and the maximum honored server TTL.
175    #[must_use]
176    pub fn with_cache(mut self, max_entries: usize, max_ttl: Duration) -> Self {
177        self.cache_max_entries = max_entries;
178        self.cache_max_ttl = max_ttl;
179        self.rebuild_cache();
180        self
181    }
182
183    /// Share results the origin marks `cacheScope: "public"` across tenants.
184    ///
185    /// Disabled by default. A `"public"` result is an assertion by the origin
186    /// that the body does not depend on who asked for it; honoring it puts one
187    /// entry in a bucket every authorization context reads, so an origin that
188    /// mislabels a tenant-specific result turns that mistake into a cross-tenant
189    /// disclosure at the edge. Cacheable methods include `resources/read`, so
190    /// the blast radius is resource contents, not just discovery listings.
191    ///
192    /// Enable this only when the origin's public results are genuinely
193    /// tenant-independent. Leaving it off costs cache hit rate and nothing else:
194    /// a cache may always decline to reuse a response.
195    #[must_use]
196    pub fn with_public_cache_sharing(mut self, share: bool) -> Self {
197        self.share_public_cache = share;
198        self.rebuild_cache();
199        self
200    }
201
202    /// Forward the API key to the inner service after authenticating it.
203    ///
204    /// Disabled by default: the edge consumes the credential, so passing it on
205    /// hands the inner service a secret it was not issued. That is harmless when
206    /// the origin runs in-process, and an exposure the moment this layer fronts
207    /// an origin in another trust domain.
208    ///
209    /// Enable it when the origin performs its own check against the same key.
210    #[must_use]
211    pub fn with_credential_forwarding(mut self, forward: bool) -> Self {
212        self.forward_credentials = forward;
213        self
214    }
215
216    /// Bound bytes accepted from one MCP request body.
217    #[must_use]
218    pub fn with_max_request_body(mut self, bytes: usize) -> Self {
219        self.max_request_body = bytes;
220        self
221    }
222
223    /// Bound bytes retained while observing a response. Oversized responses
224    /// continue streaming but fail toward not charging and not caching.
225    #[must_use]
226    pub fn with_max_response_capture(mut self, bytes: usize) -> Self {
227        self.max_response_capture = bytes;
228        self
229    }
230
231    /// Shared edge counters.
232    #[must_use]
233    pub fn metrics(&self) -> Arc<EdgeMetrics> {
234        self.metrics.clone()
235    }
236
237    /// Terminal accounting parked because it could not finish without awaiting.
238    ///
239    /// Only a durable task store parks anything: its futures perform real I/O,
240    /// and the body is released from `Drop`, where nothing may await. Every
241    /// subsequent authenticated request runs a bounded number of them, so an
242    /// application that ignores this still converges. Draining explicitly is
243    /// better, and draining after graceful shutdown is what stops a departing
244    /// process from taking durable-task charges with it.
245    ///
246    /// Take the handle before passing the configuration to the layer.
247    #[must_use]
248    pub fn deferred(&self) -> Arc<DeferredCompletions> {
249        self.deferred.clone()
250    }
251
252    /// Bound how much parked accounting each authenticated request runs.
253    ///
254    /// Zero leaves draining entirely to [`EdgeConfig::deferred`]. Raising it
255    /// clears a backlog sooner at the cost of latency on the requests that do
256    /// the work.
257    #[must_use]
258    pub fn with_deferred_drain_per_request(mut self, completions: usize) -> Self {
259        self.deferred_drain_per_request = completions;
260        self
261    }
262
263    /// Bound how many completions may be parked before the oldest is discarded.
264    #[must_use]
265    pub fn with_deferred_capacity(mut self, capacity: usize) -> Self {
266        self.deferred = Arc::new(DeferredCompletions::new(capacity));
267        self
268    }
269
270    /// Refuse further guessing after `max_failures` bad credentials in `window`.
271    ///
272    /// Disabled by default, because the useful ceiling depends entirely on how
273    /// many clients an edge serves. Only failures consume the budget, so a
274    /// caller with a valid key is never affected: exhausting it turns a wrong
275    /// key's `401` into a `429` and nothing more.
276    ///
277    /// This bounds sustained guessing across the whole edge. It is not
278    /// per-client limiting, which needs a client identity this layer cannot
279    /// trust, and belongs in the proxy in front of it.
280    ///
281    /// # Errors
282    ///
283    /// Returns [`EdgeConfigError::InvalidAuthFailureWindow`] when `window` is
284    /// zero.
285    pub fn with_auth_failure_limit(
286        mut self,
287        max_failures: u64,
288        window: Duration,
289    ) -> Result<Self, EdgeConfigError> {
290        if window.is_zero() {
291            return Err(EdgeConfigError::InvalidAuthFailureWindow);
292        }
293        self.auth_failure_limit = Some(Arc::new(AuthFailureLimit::new(max_failures, window)));
294        Ok(self)
295    }
296}
297
298/// Tower layer that installs [`MeterService`].
299#[derive(Debug, Clone)]
300pub struct MeterLayer {
301    config: Arc<EdgeConfig>,
302}
303
304impl MeterLayer {
305    /// Construct a metering layer from its shared configuration.
306    #[must_use]
307    pub fn new(config: EdgeConfig) -> Self {
308        Self {
309            config: Arc::new(config),
310        }
311    }
312}
313
314impl<S> Layer<S> for MeterLayer {
315    type Service = MeterService<S>;
316
317    fn layer(&self, inner: S) -> Self::Service {
318        MeterService {
319            inner,
320            config: self.config.clone(),
321        }
322    }
323}
324
325/// Service produced by [`MeterLayer`].
326#[derive(Debug, Clone)]
327pub struct MeterService<S> {
328    inner: S,
329    config: Arc<EdgeConfig>,
330}
331
332impl<S, RequestBody, ResponseBody> Service<Request<RequestBody>> for MeterService<S>
333where
334    RequestBody: Body<Data = Bytes> + Send + 'static,
335    S: Service<Request<Full<Bytes>>, Response = Response<ResponseBody>> + Clone + Send + 'static,
336    S::Future: Send + 'static,
337    S::Error: Send + 'static,
338    ResponseBody: Body<Data = Bytes> + Send + 'static,
339    ResponseBody::Error: StdError + Send + Sync + 'static,
340{
341    type Response = Response<MeterBody>;
342    type Error = S::Error;
343    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
344
345    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
346        self.inner.poll_ready(cx)
347    }
348
349    #[expect(
350        clippy::too_many_lines,
351        reason = "the request lifecycle stays linear so security-gate ordering is auditable"
352    )]
353    fn call(&mut self, request: Request<RequestBody>) -> Self::Future {
354        let replacement = self.inner.clone();
355        let mut inner = std::mem::replace(&mut self.inner, replacement);
356        let config = self.config.clone();
357
358        Box::pin(async move {
359            let (mut parts, request_body) = request.into_parts();
360            // Classification runs before authentication so a malformed request is
361            // refused without consulting the tenant store. A legacy request cannot be
362            // classified from headers at all, so it is deferred to the body - but only
363            // after the credential gate below, so unauthenticated traffic never buys a
364            // JSON parse.
365            let classification =
366                match classify_request_headers(&parts.headers, config.strict_protocol_version) {
367                    Ok(classification) => classification,
368                    Err(message) => {
369                        config.metrics.rejected();
370                        return Ok(error_response(
371                            StatusCode::BAD_REQUEST,
372                            "INVALID_MCP_HEADERS",
373                            &message,
374                        ));
375                    }
376                };
377            let api_key = match extract_api_key(&parts.headers) {
378                Ok(api_key) => api_key,
379                Err(error) => {
380                    let message = match error {
381                        CredentialError::Missing => "missing API key",
382                        CredentialError::Invalid => "invalid or ambiguous API key headers",
383                    };
384                    return Ok(refuse_credential(&config, message));
385                }
386            };
387            let Some(tenant) = config.tenants.authenticate(api_key) else {
388                return Ok(refuse_credential(&config, "invalid API key"));
389            };
390
391            // Only authenticated traffic may borrow the request path to drive
392            // earlier backend work. Malformed headers and failed credentials
393            // return above without polling a deferred completion.
394            if config.deferred_drain_per_request > 0 {
395                config
396                    .deferred
397                    .drain_some(config.deferred_drain_per_request)
398                    .await;
399            }
400            let authorization_context = hash_api_key(api_key);
401            config.metrics.classified();
402
403            let request_bytes =
404                match collect_request_body(request_body, config.max_request_body).await {
405                    Ok(bytes) => bytes,
406                    Err(RequestBodyError::TooLarge) => {
407                        config.metrics.rejected();
408                        return Ok(error_response(
409                            StatusCode::PAYLOAD_TOO_LARGE,
410                            "BODY_TOO_LARGE",
411                            "MCP request body exceeds the configured limit",
412                        ));
413                    }
414                    Err(RequestBodyError::Read) => {
415                        config.metrics.rejected();
416                        return Ok(error_response(
417                            StatusCode::BAD_REQUEST,
418                            "INVALID_BODY",
419                            "failed to read MCP request body",
420                        ));
421                    }
422                };
423            let legacy = classification.is_from_body();
424            let protocol = match classification.resolve(&parts.method, &request_bytes) {
425                Ok(protocol) => protocol,
426                Err(message) => {
427                    config.metrics.rejected();
428                    return Ok(error_response(
429                        StatusCode::BAD_REQUEST,
430                        "INVALID_MCP_HEADERS",
431                        &message,
432                    ));
433                }
434            };
435            let Some(protocol) = protocol else {
436                if !config.forward_credentials {
437                    parts.headers.remove(crate::API_KEY_HEADER);
438                    parts.headers.remove(AUTHORIZATION);
439                }
440                let response = inner
441                    .call(Request::from_parts(parts, Full::new(request_bytes)))
442                    .await?;
443                return Ok(response.map(|body| {
444                    body.map_err(|error| -> BoxError { Box::new(error) })
445                        .boxed_unsync()
446                }));
447            };
448            let metadata = inspect_request(&protocol.call, &request_bytes);
449            if let Some(key) = metadata.cache_key
450                && let Some(cached) = config.cache.get(key, &authorization_context)
451            {
452                config.metrics.cache_hit();
453                let mut cached_request = metadata;
454                cached_request.cache_key = None;
455                let content_type = cached
456                    .headers
457                    .get(CONTENT_TYPE)
458                    .and_then(|value| value.to_str().ok())
459                    .unwrap_or_default()
460                    .to_owned();
461                let body = render_with_id(&cached.body, cached_request.request_id.as_ref());
462                let completion = Completion {
463                    config,
464                    tenant,
465                    authorization_context,
466                    legacy,
467                    call: protocol.call,
468                    request: cached_request,
469                    response_status: cached.status,
470                    response_version: cached.version,
471                    response_headers: cached.headers.clone(),
472                    content_type,
473                };
474                return Ok(cached_response(&cached, body, completion));
475            }
476            if metadata.cache_key.is_some() {
477                config.metrics.cache_miss();
478            }
479
480            // The credential was consumed by the gate above. Removing both
481            // headers unconditionally is safe: `extract_api_key` rejects a
482            // request carrying both, so at most one of them is present.
483            if !config.forward_credentials {
484                parts.headers.remove(crate::API_KEY_HEADER);
485                parts.headers.remove(AUTHORIZATION);
486            }
487            let request = Request::from_parts(parts, Full::new(request_bytes));
488            let response = inner.call(request).await?;
489            let (parts, body) = response.into_parts();
490            let completion = Completion {
491                config,
492                tenant,
493                authorization_context,
494                legacy,
495                call: protocol.call,
496                request: metadata,
497                response_status: parts.status,
498                response_version: parts.version,
499                response_headers: cacheable_headers(&parts.headers),
500                content_type: parts
501                    .headers
502                    .get(CONTENT_TYPE)
503                    .and_then(|value| value.to_str().ok())
504                    .unwrap_or_default()
505                    .to_owned(),
506            };
507            let observed = ObservedBody::new(body, completion);
508            let boxed = observed
509                .map_err(|error| -> BoxError { Box::new(error) })
510                .boxed_unsync();
511            Ok(Response::from_parts(parts, boxed))
512        })
513    }
514}
515
516/// Validated identity shared by pricing and metering.
517#[derive(Debug, Clone)]
518pub struct ClassifiedCall {
519    /// The call the origin will execute.
520    pub call: Call,
521}
522
523/// The outcome of looking at the request headers alone.
524#[derive(Debug, Clone)]
525pub enum Classification {
526    /// The revision mandates header/body validation, so the headers priced the call
527    /// and the body is never parsed.
528    FromHeaders(ClassifiedCall),
529    /// A legacy revision. The call is priced from the body once it has been read.
530    FromBody,
531}
532
533impl Classification {
534    /// Whether the call was priced from the body rather than from trusted headers.
535    #[must_use]
536    pub const fn is_from_body(&self) -> bool {
537        matches!(self, Self::FromBody)
538    }
539
540    /// Finish classification, reading the body only when the headers could not be
541    /// trusted. Called after the credential gate.
542    /// Transport operations and valid client responses return `None`.
543    ///
544    /// # Errors
545    /// Returns an error when the legacy body cannot be classified safely.
546    pub fn resolve(
547        self,
548        http_method: &http::Method,
549        body: &[u8],
550    ) -> Result<Option<ClassifiedCall>, String> {
551        match self {
552            Self::FromHeaders(classified) => Ok(Some(classified)),
553            Self::FromBody => {
554                if matches!(*http_method, http::Method::GET | http::Method::DELETE) {
555                    return if body.is_empty() {
556                        Ok(None)
557                    } else {
558                        Err("transport requests must have an empty body".into())
559                    };
560                }
561                // A response to a server request is transport traffic, never a new call.
562                // Require a response envelope; arbitrary methodless objects still fail.
563                if let Ok(value) = serde_json::from_slice::<Value>(body)
564                    && value.get("method").is_none()
565                    && value.get("jsonrpc").and_then(Value::as_str) == Some("2.0")
566                    && value
567                        .get("id")
568                        .is_some_and(|id| id.is_string() || id.is_number())
569                    && (value.get("result").is_some() ^ value.get("error").is_some())
570                {
571                    return Ok(None);
572                }
573                let (method, name) =
574                    mcp_usage_core::classify_body(body).map_err(|error| error.to_string())?;
575                if method.as_str().starts_with("notifications/") {
576                    return Ok(None);
577                }
578                Ok(Some(ClassifiedCall {
579                    call: Call::new(method, name),
580                }))
581            }
582        }
583    }
584}
585
586/// Validate unique protocol headers before authenticating or reading a body.
587///
588/// # Errors
589/// Returns an error for duplicate, malformed, or disallowed protocol headers.
590pub fn classify_request_headers(
591    headers: &HeaderMap,
592    strict: bool,
593) -> Result<Classification, String> {
594    let version = optional_header_str(headers, PROTOCOL_VERSION_HEADER)?;
595    let method = optional_header_str(headers, METHOD_HEADER)?;
596    let name = optional_header_str(headers, NAME_HEADER)?;
597
598    // Trust is settled before anything else, because "this client is old" and "this
599    // client is broken" deserve different answers.
600    if let Err(failure) = validate_header(version) {
601        return match failure {
602            // A version that is not a date is not a revision we can reason about. It is
603            // a malfunctioning or probing client, not a legacy one, so it is refused
604            // whatever the fallback setting - reading its body would reward the garbage.
605            TrustFailure::MalformedVersion(_) => {
606                Err(ClassificationError::UntrustedVersion(failure.to_string()).to_string())
607            }
608            // Absent or pre-validation revisions are the ordinary legacy client. Price
609            // it from the body unless this deployment asked to serve only modern ones.
610            TrustFailure::MissingVersionHeader | TrustFailure::UnvalidatedRevision(_) => {
611                if strict {
612                    Err(ClassificationError::UntrustedVersion(failure.to_string()).to_string())
613                } else {
614                    Ok(Classification::FromBody)
615                }
616            }
617        };
618    }
619
620    // Trusted revision: the origin is obliged to reject header/body disagreement, so the
621    // headers price the call and the body is never parsed. A modern request with a
622    // missing or malformed Mcp-Method is still an error - it claimed to mirror headers.
623    let classified = classify_protocol_headers(version, method, name).map_err(|e| e.to_string())?;
624    Ok(Classification::FromHeaders(ClassifiedCall {
625        call: Call::new(
626            classified.method,
627            classified.name.map(std::borrow::Cow::into_owned),
628        ),
629    }))
630}
631
632fn optional_header_str<'a>(headers: &'a HeaderMap, name: &str) -> Result<Option<&'a str>, String> {
633    unique_header(headers, name)?
634        .map(|value| {
635            value
636                .to_str()
637                .map_err(|_| format!("{name} is not a visible ASCII header value"))
638        })
639        .transpose()
640}
641
642fn unique_header<'a>(
643    headers: &'a HeaderMap,
644    name: &str,
645) -> Result<Option<&'a http::HeaderValue>, String> {
646    let mut values = headers.get_all(name).iter();
647    let value = values.next();
648    if values.next().is_some() {
649        return Err(format!("multiple {name} headers are not allowed"));
650    }
651    Ok(value)
652}
653
654/// Missing or ambiguous tenant credentials.
655#[derive(Debug, Clone, Copy, PartialEq, Eq)]
656pub enum CredentialError {
657    /// No credential was presented.
658    Missing,
659    /// A credential was malformed or ambiguous.
660    Invalid,
661}
662
663/// Extract exactly one API key or Bearer credential.
664///
665/// # Errors
666/// Rejects missing, duplicate, or ambiguous credentials.
667pub fn extract_api_key(headers: &HeaderMap) -> Result<&str, CredentialError> {
668    let direct =
669        unique_header(headers, crate::API_KEY_HEADER).map_err(|_| CredentialError::Invalid)?;
670    let authorization =
671        unique_header(headers, AUTHORIZATION.as_str()).map_err(|_| CredentialError::Invalid)?;
672    let value = match (direct, authorization) {
673        (None, None) => return Err(CredentialError::Missing),
674        (Some(_), Some(_)) => return Err(CredentialError::Invalid),
675        (Some(value), None) => value.to_str().map_err(|_| CredentialError::Invalid)?,
676        (None, Some(value)) => bearer_token(value.to_str().map_err(|_| CredentialError::Invalid)?)
677            .ok_or(CredentialError::Invalid)?,
678    };
679    if value.is_empty() {
680        return Err(CredentialError::Invalid);
681    }
682    Ok(value)
683}
684
685/// The token from an `Authorization: Bearer` credential.
686///
687/// The scheme name is matched case-insensitively, because RFC 7235 defines it
688/// that way and real clients send `bearer`. Matching it exactly turns a
689/// conforming request into a `401` that looks, from the caller's side, exactly
690/// like a wrong key. RFC 7235 also permits more than one space before the
691/// token, while RFC 6750's `token68` cannot itself begin with one, so leading
692/// space is separator rather than credential.
693fn bearer_token(value: &str) -> Option<&str> {
694    let (scheme, token) = value.split_once(' ')?;
695    scheme
696        .eq_ignore_ascii_case("bearer")
697        .then(|| token.trim_start_matches(' '))
698}
699
700/// Refuse a request whose credential did not authenticate.
701///
702/// The failure budget is consulted only here, on the path a caller reaches by
703/// presenting a credential that did not work. A valid key never passes through
704/// this function, which is what makes a global budget safe: guessing cannot
705/// throttle callers who are not guessing.
706fn refuse_credential(config: &EdgeConfig, message: &str) -> Response<MeterBody> {
707    if let Some(limit) = config.auth_failure_limit.as_ref() {
708        if limit.is_exhausted() {
709            config.metrics.throttled();
710            return error_response(
711                StatusCode::TOO_MANY_REQUESTS,
712                "TOO_MANY_FAILED_CREDENTIALS",
713                "too many failed authentication attempts",
714            );
715        }
716        limit.record_failure();
717    }
718    config.metrics.unauthenticated();
719    error_response(StatusCode::UNAUTHORIZED, "UNAUTHENTICATED", message)
720}
721
722fn error_response(status: StatusCode, code: &str, message: &str) -> Response<MeterBody> {
723    let bytes = Bytes::from(json!({"error":{"code":code,"message":message}}).to_string());
724    let mut response = Response::new(
725        Full::new(bytes)
726            .map_err(|never: Infallible| -> BoxError { match never {} })
727            .boxed_unsync(),
728    );
729    *response.status_mut() = status;
730    let headers = response.headers_mut();
731    headers.insert(
732        CONTENT_TYPE,
733        http::HeaderValue::from_static("application/json"),
734    );
735    // The message can echo a rejected header value back to the caller, so pin
736    // the interpretation to JSON rather than leaving it to content sniffing.
737    headers.insert(
738        X_CONTENT_TYPE_OPTIONS,
739        http::HeaderValue::from_static("nosniff"),
740    );
741    headers.insert(CACHE_CONTROL, http::HeaderValue::from_static("no-store"));
742    response
743}
744
745fn cached_response(
746    cached: &CachedResponse,
747    body: String,
748    completion: Completion,
749) -> Response<MeterBody> {
750    let shares_public = completion.config.share_public_cache;
751    let observed = ObservedBody::new(Full::new(Bytes::from(body)), completion);
752    let mut response = Response::new(
753        observed
754            .map_err(|never: Infallible| -> BoxError { match never {} })
755            .boxed_unsync(),
756    );
757    *response.status_mut() = cached.status;
758    *response.version_mut() = cached.version;
759    *response.headers_mut() = cached.headers.clone();
760    let headers = response.headers_mut();
761    headers.insert("x-mcp-usage-cache", http::HeaderValue::from_static("hit"));
762    headers.insert(
763        X_CONTENT_TYPE_OPTIONS,
764        http::HeaderValue::from_static("nosniff"),
765    );
766    if !shares_public {
767        // Nothing here is safe for a shared cache to reuse across callers, and a
768        // CDN in front of the edge has no idea the body is keyed on an API key.
769        headers.insert(CACHE_CONTROL, http::HeaderValue::from_static("private"));
770    }
771    response
772}
773
774fn cacheable_headers(headers: &HeaderMap) -> HeaderMap {
775    let mut kept = HeaderMap::new();
776    if let Some(content_type) = headers.get(CONTENT_TYPE) {
777        kept.insert(CONTENT_TYPE, content_type.clone());
778    }
779    kept
780}
781
782struct Completion {
783    config: Arc<EdgeConfig>,
784    tenant: Arc<Tenant>,
785    authorization_context: String,
786    /// The request predated mirrored headers, so its response predates `resultType`.
787    legacy: bool,
788    call: Call,
789    request: RequestMetadata,
790    response_status: StatusCode,
791    response_version: http::Version,
792    response_headers: HeaderMap,
793    content_type: String,
794}
795
796impl Completion {
797    #[expect(
798        clippy::too_many_lines,
799        reason = "terminal accounting stays linear so fail-free ordering remains auditable"
800    )]
801    async fn finish(self, bytes: Bytes) {
802        let body = match terminal_response(&self.content_type, &bytes) {
803            Ok(body) => body,
804            Err(reason) => {
805                self.config.metrics.unrecognized_response(reason);
806                return;
807            }
808        };
809        let response = mcp_usage_core::peek::response(&body);
810        // A legacy server cannot say "complete"; on those revisions a result is the
811        // delivered work. Without this every legacy call would meter as free.
812        let response = if self.legacy {
813            response.with_legacy_delivery(&self.call.method)
814        } else {
815            response
816        };
817
818        let response_task_id = response
819            .task
820            .as_ref()
821            .and_then(|task| task.task_id.as_deref());
822        let requested_task_id = self.request.requested_task_id.as_deref();
823        if matches!(self.call.method, Method::TasksGet)
824            && let (Some(requested), Some(returned)) = (requested_task_id, response_task_id)
825            && requested != returned
826        {
827            // Never attribute one task's result to a differently requested
828            // task. A conforming origin cannot produce this mismatch.
829            self.config
830                .metrics
831                .unrecognized_response(UnaccountedReason::TaskIdMismatch);
832            return;
833        }
834
835        if matches!(response.result_type, ResultType::Task)
836            && let Some(task_id) = response
837                .task
838                .as_ref()
839                .and_then(|task| task.task_id.as_deref())
840            && let Err(error) = self
841                .config
842                .tasks
843                .insert(
844                    &self.tenant.id,
845                    task_id,
846                    TaskAttribution::from_call(&self.call, &self.tenant.prices),
847                )
848                .await
849        {
850            self.config.metrics.record_failure();
851            tracing::error!(error = %error, "failed to retain MCP task attribution");
852        }
853
854        let cleanup_task_id = response_task_id.or(requested_task_id);
855        let terminal_task = response.task.as_ref().is_some_and(|task| {
856            matches!(
857                task.status,
858                TaskStatus::Completed | TaskStatus::Failed | TaskStatus::Cancelled
859            )
860        });
861        let completed_task = response
862            .task
863            .as_ref()
864            .is_some_and(|task| matches!(task.status, TaskStatus::Completed));
865        let task_origin =
866            if matches!(self.call.method, Method::TasksGet) && completed_task && !response.is_error
867            {
868                if let Some(task_id) = response_task_id {
869                    match self.config.tasks.claim(&self.tenant.id, task_id).await {
870                        Ok(origin) => origin,
871                        Err(error) => {
872                            self.config.metrics.record_failure();
873                            tracing::error!(error = %error, "failed to claim MCP task attribution");
874                            None
875                        }
876                    }
877                } else {
878                    None
879                }
880            } else {
881                None
882            };
883        let charge = decide_with_task_attribution(
884            &self.call,
885            &response,
886            &self.tenant.prices,
887            task_origin.as_ref(),
888        );
889        match charge {
890            Charge::Billable(billable) => {
891                let usage = UsageEvent::now(
892                    &self.tenant.id,
893                    &self.tenant.billing_customer_id,
894                    &self.config.meter_name,
895                    billable.units,
896                    billable.idempotency_key,
897                );
898                match self.config.recorder.record(usage) {
899                    Ok(RecordOutcome::Recorded | RecordOutcome::ZeroUnits) => {
900                        self.config.metrics.billed(billable.units);
901                    }
902                    Ok(RecordOutcome::Duplicate) => {
903                        self.config.metrics.duplicate();
904                    }
905                    Err(error) => {
906                        self.config.metrics.record_failure();
907                        tracing::error!(error = %error, "failed to buffer MCP usage");
908                        if let (Some(task_id), Some(origin)) = (response_task_id, task_origin)
909                            && let Err(store_error) = self
910                                .config
911                                .tasks
912                                .insert(&self.tenant.id, task_id, origin)
913                                .await
914                        {
915                            self.config.metrics.record_failure();
916                            tracing::error!(
917                                error = %store_error,
918                                "failed to restore MCP task attribution after recorder rejection"
919                            );
920                        }
921                    }
922                }
923            }
924            Charge::Free(reason) => {
925                self.config.metrics.free(reason);
926                // The claim above already deleted the attribution, on the
927                // assumption that a completed task bills. Some shapes do not:
928                // a `tasks/get` answering with `resultType: "task"` reads as
929                // a task creation and comes back Free. Without putting it
930                // back the charge is destroyed permanently and every later
931                // poll reports a missing attribution.
932                if let (Some(task_id), Some(origin)) = (response_task_id, task_origin)
933                    && let Err(error) = self
934                        .config
935                        .tasks
936                        .insert(&self.tenant.id, task_id, origin)
937                        .await
938                {
939                    self.config.metrics.record_failure();
940                    tracing::error!(
941                        error = %error,
942                        "failed to restore MCP task attribution after a free verdict"
943                    );
944                }
945                if terminal_task
946                    && !completed_task
947                    && let Some(task_id) = cleanup_task_id
948                    && let Err(error) = self.config.tasks.remove(&self.tenant.id, task_id).await
949                {
950                    self.config.metrics.record_failure();
951                    tracing::error!(error = %error, "failed to remove MCP task attribution");
952                }
953            }
954        }
955
956        if self.response_status.is_success()
957            && !self.request.is_continuation
958            && has_media_type(&self.content_type, "application/json")
959            && let Some(key) = self.request.cache_key
960            && let Some((ttl, scope)) = cache_hints(&body)
961        {
962            self.config.cache.insert(
963                key,
964                &self.authorization_context,
965                scope,
966                ttl,
967                CachedResponse {
968                    status: self.response_status,
969                    version: self.response_version,
970                    headers: self.response_headers,
971                    body,
972                },
973            );
974        }
975    }
976}
977
978struct ObservedBody<B> {
979    inner: Pin<Box<B>>,
980    completion: Option<Completion>,
981    finishing: Option<Pin<Box<dyn Future<Output = ()> + Send>>>,
982    captured: BytesMut,
983    overflowed: bool,
984    metrics: Arc<EdgeMetrics>,
985    deferred: Arc<DeferredCompletions>,
986}
987
988impl<B> ObservedBody<B> {
989    fn new(inner: B, completion: Completion) -> Self {
990        let metrics = completion.config.metrics.clone();
991        let deferred = completion.config.deferred.clone();
992        Self {
993            inner: Box::pin(inner),
994            completion: Some(completion),
995            finishing: None,
996            captured: BytesMut::new(),
997            overflowed: false,
998            metrics,
999            deferred,
1000        }
1001    }
1002
1003    /// Build the terminal-accounting future, consuming the pending completion.
1004    ///
1005    /// Returns `None` when there is nothing left to account for, either because
1006    /// accounting already ran or because the captured body overflowed.
1007    fn start_completion(&mut self) -> Option<Pin<Box<dyn Future<Output = ()> + Send>>> {
1008        let completion = self.completion.take()?;
1009        if self.overflowed {
1010            completion
1011                .config
1012                .metrics
1013                .unrecognized_response(UnaccountedReason::Oversized);
1014            return None;
1015        }
1016        let captured = std::mem::take(&mut self.captured).freeze();
1017        Some(Box::pin(completion.finish(captured)))
1018    }
1019}
1020
1021impl<B> Drop for ObservedBody<B> {
1022    /// Account for a response the transport stopped polling before end-of-stream.
1023    ///
1024    /// A body that declares a `Content-Length` is not polled again once that
1025    /// many bytes have been written, so `poll_frame` never observes the
1026    /// end-of-stream that terminal accounting used to depend on. That is the
1027    /// ordinary case for a fixed-length JSON result, and without this the meter
1028    /// records nothing at all. Dropping the body is the one signal every
1029    /// transport does give us.
1030    ///
1031    /// Nothing here may await, so the future is polled once with a no-op waker.
1032    /// That settles it outright for every synchronous recorder and for the
1033    /// in-process task store, whose futures never yield.
1034    ///
1035    /// A durable store performs real I/O, so its first poll pends. That work is
1036    /// parked on [`DeferredCompletions`] to be driven from somewhere that can
1037    /// await, because the paths that pend are the ones carrying durable-task
1038    /// attribution: abandoning them loses the charge for every durable task.
1039    fn drop(&mut self) {
1040        let mut context = Context::from_waker(std::task::Waker::noop());
1041        let unfinished = if let Some(mut finishing) = self.finishing.take() {
1042            finishing
1043                .as_mut()
1044                .poll(&mut context)
1045                .is_pending()
1046                .then_some(finishing)
1047        } else if let Some(mut finishing) = self.start_completion() {
1048            finishing
1049                .as_mut()
1050                .poll(&mut context)
1051                .is_pending()
1052                .then_some(finishing)
1053        } else {
1054            None
1055        };
1056        if let Some(finishing) = unfinished {
1057            self.metrics.deferred();
1058            if !self.deferred.park(finishing) {
1059                // The queue was full, so the oldest accounting was discarded.
1060                self.metrics.record_failure();
1061            }
1062        }
1063    }
1064}
1065
1066impl<B> Body for ObservedBody<B>
1067where
1068    B: Body<Data = Bytes>,
1069{
1070    type Data = Bytes;
1071    type Error = B::Error;
1072
1073    fn poll_frame(
1074        mut self: Pin<&mut Self>,
1075        cx: &mut Context<'_>,
1076    ) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
1077        if let Some(finishing) = self.finishing.as_mut() {
1078            return match finishing.as_mut().poll(cx) {
1079                Poll::Ready(()) => {
1080                    self.finishing = None;
1081                    Poll::Ready(None)
1082                }
1083                Poll::Pending => Poll::Pending,
1084            };
1085        }
1086        match self.inner.as_mut().poll_frame(cx) {
1087            Poll::Ready(Some(Ok(frame))) => {
1088                if let Some(data) = frame.data_ref()
1089                    && !self.overflowed
1090                {
1091                    let limit = self
1092                        .completion
1093                        .as_ref()
1094                        .map_or(0, |completion| completion.config.max_response_capture);
1095                    if self.captured.len().saturating_add(data.remaining()) <= limit {
1096                        self.captured.extend_from_slice(data.chunk());
1097                    } else {
1098                        self.captured.clear();
1099                        self.overflowed = true;
1100                    }
1101                }
1102                Poll::Ready(Some(Ok(frame)))
1103            }
1104            Poll::Ready(None) => {
1105                if let Some(mut finishing) = self.start_completion() {
1106                    return match finishing.as_mut().poll(cx) {
1107                        Poll::Ready(()) => Poll::Ready(None),
1108                        Poll::Pending => {
1109                            self.finishing = Some(finishing);
1110                            Poll::Pending
1111                        }
1112                    };
1113                }
1114                Poll::Ready(None)
1115            }
1116            other => other,
1117        }
1118    }
1119
1120    fn is_end_stream(&self) -> bool {
1121        self.inner.is_end_stream()
1122    }
1123
1124    fn size_hint(&self) -> http_body::SizeHint {
1125        self.inner.size_hint()
1126    }
1127}
1128
1129/// A JSON-RPC response, if these bytes are one.
1130///
1131/// Both transports apply the same test, because "terminal" means a JSON-RPC
1132/// response and not merely well-formed JSON. A bare `0` is valid JSON and is not
1133/// a response to anything.
1134fn is_json_rpc_response(value: &Value) -> bool {
1135    value.get("result").is_some() || value.get("error").is_some()
1136}
1137
1138/// Read the terminal JSON-RPC response out of a body, or say why there is none.
1139///
1140/// The two failures are not the same event and must not share a counter. A
1141/// media type the meter does not read means the origin is misconfigured and
1142/// every call through it is billing nothing, which is a revenue incident; a
1143/// body that was read and was not a response is the origin answering with
1144/// something else, which is not.
1145///
1146/// # Why an undeclared type is still not read
1147///
1148/// An origin that sends no `Content-Type`, or one the meter does not
1149/// recognize, bills nothing even when the body is a perfectly good JSON-RPC
1150/// response. That is deliberate. The meter charges money, and it will not
1151/// infer a charge from a body whose sender did not say it was MCP - the
1152/// guarantee that an unexpected content type can never carry a charge is worth
1153/// more than the calls an out-of-spec origin loses.
1154///
1155/// What was wrong before was not the refusal but its silence: the refusal
1156/// shared one counter with three unrelated causes, so it was invisible.
1157/// `mcp_usage_unrecognized_responses_by_reason_total{reason="media_type"}`
1158/// isolates it. A nonzero rate there means an origin is misconfigured and its
1159/// traffic is billing nothing - alert on it.
1160pub(crate) fn terminal_response(
1161    content_type: &str,
1162    bytes: &[u8],
1163) -> Result<Value, UnaccountedReason> {
1164    if has_media_type(content_type, "application/json") {
1165        return serde_json::from_slice(bytes)
1166            .ok()
1167            .filter(is_json_rpc_response)
1168            .ok_or(UnaccountedReason::Unparseable);
1169    }
1170    if !has_media_type(content_type, "text/event-stream") {
1171        return Err(UnaccountedReason::MediaType);
1172    }
1173    let Ok(text) = std::str::from_utf8(bytes) else {
1174        return Err(UnaccountedReason::Unparseable);
1175    };
1176    // One pass over the borrowed bytes. Normalizing line endings by rewriting
1177    // the text copied the whole captured body twice, once per `replace`, for
1178    // every streamed response the meter observed.
1179    let mut terminal = None;
1180    let mut data = String::new();
1181    for line in sse_lines(text) {
1182        if line.is_empty() {
1183            // A blank line ends the event.
1184            take_terminal(&data, &mut terminal);
1185            data.clear();
1186            continue;
1187        }
1188        if let Some(value) = line.strip_prefix("data:") {
1189            if !data.is_empty() {
1190                data.push('\n');
1191            }
1192            data.push_str(value.trim_start());
1193        }
1194    }
1195    take_terminal(&data, &mut terminal);
1196    terminal.ok_or(UnaccountedReason::Unparseable)
1197}
1198
1199/// Keep `data` if it is a JSON-RPC response, replacing any earlier one.
1200///
1201/// The last terminal response in the stream wins, which is what the buffered
1202/// implementation did by overwriting as it went.
1203fn take_terminal(data: &str, terminal: &mut Option<Value>) {
1204    if data.is_empty() {
1205        return;
1206    }
1207    if let Ok(value) = serde_json::from_str::<Value>(data)
1208        && is_json_rpc_response(&value)
1209    {
1210        *terminal = Some(value);
1211    }
1212}
1213
1214/// Split SSE text on `\n`, `\r\n`, or a bare `\r`.
1215///
1216/// The wire format treats all three as the same terminator. Yielding borrowed
1217/// slices is the point: it is what lets the reader avoid rewriting the body to
1218/// normalize it.
1219fn sse_lines(text: &str) -> impl Iterator<Item = &str> {
1220    let mut rest = Some(text);
1221    std::iter::from_fn(move || {
1222        let remaining = rest?;
1223        let Some(at) = remaining.find(['\n', '\r']) else {
1224            rest = None;
1225            return Some(remaining);
1226        };
1227        let (line, after) = remaining.split_at(at);
1228        let terminator = if after.starts_with("\r\n") { 2 } else { 1 };
1229        rest = Some(&after[terminator..]);
1230        Some(line)
1231    })
1232}
1233
1234fn has_media_type(content_type: &str, expected: &str) -> bool {
1235    content_type
1236        .split(';')
1237        .next()
1238        .is_some_and(|media_type| media_type.trim().eq_ignore_ascii_case(expected))
1239}
1240
1241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1242enum RequestBodyError {
1243    TooLarge,
1244    Read,
1245}
1246
1247async fn collect_request_body<B>(body: B, limit: usize) -> Result<Bytes, RequestBodyError>
1248where
1249    B: Body<Data = Bytes>,
1250{
1251    let initial_capacity = usize::try_from(body.size_hint().lower())
1252        .unwrap_or(limit)
1253        .min(limit);
1254    let mut captured = BytesMut::with_capacity(initial_capacity);
1255    let mut body = Box::pin(body);
1256    while let Some(frame) = body.as_mut().frame().await {
1257        let frame = frame.map_err(|_| RequestBodyError::Read)?;
1258        let Ok(data) = frame.into_data() else {
1259            continue;
1260        };
1261        let Some(next_len) = captured.len().checked_add(data.len()) else {
1262            return Err(RequestBodyError::TooLarge);
1263        };
1264        if next_len > limit {
1265            return Err(RequestBodyError::TooLarge);
1266        }
1267        captured.extend_from_slice(&data);
1268    }
1269    Ok(captured.freeze())
1270}
1271
1272/// Internals reachable from the crate's property tests.
1273#[cfg(test)]
1274pub(crate) mod testing {
1275    use serde_json::Value;
1276
1277    /// The reader as the totality properties want it: a response, or nothing.
1278    ///
1279    /// The production reader distinguishes the two ways there can be no
1280    /// response so that the metrics can; the totality properties do not care
1281    /// which one happened.
1282    pub(crate) fn terminal_response(content_type: &str, bytes: &[u8]) -> Option<Value> {
1283        super::terminal_response(content_type, bytes).ok()
1284    }
1285}
1286
1287#[cfg(test)]
1288mod tests {
1289    use super::*;
1290    use crate::{InMemoryTenantStore, Tenant};
1291    use mcp_usage_core::{FreeReason, PriceBook};
1292    use mcp_usage_export::{
1293        AggregatedUsage, BatchExporter, BillingPipeline, ExportFuture, RecordError, UsageRecorder,
1294    };
1295    use tower::{ServiceBuilder, ServiceExt, service_fn};
1296
1297    #[derive(Debug, Default)]
1298    struct CaptureExporter {
1299        exported: std::sync::Mutex<Vec<AggregatedUsage>>,
1300    }
1301
1302    impl CaptureExporter {
1303        fn exported(&self) -> Vec<AggregatedUsage> {
1304            self.exported
1305                .lock()
1306                .unwrap_or_else(std::sync::PoisonError::into_inner)
1307                .clone()
1308        }
1309    }
1310
1311    impl BatchExporter for CaptureExporter {
1312        fn export<'a>(&'a self, batch: &'a [AggregatedUsage]) -> ExportFuture<'a> {
1313            Box::pin(async move {
1314                self.exported
1315                    .lock()
1316                    .unwrap_or_else(std::sync::PoisonError::into_inner)
1317                    .extend_from_slice(batch);
1318                Ok(())
1319            })
1320        }
1321    }
1322
1323    #[derive(Debug, Default)]
1324    struct RejectOnceRecorder {
1325        rejected: std::sync::atomic::AtomicBool,
1326        recorded: std::sync::Mutex<Vec<UsageEvent>>,
1327    }
1328
1329    impl UsageRecorder for RejectOnceRecorder {
1330        fn record(&self, event: UsageEvent) -> Result<RecordOutcome, RecordError> {
1331            if !self
1332                .rejected
1333                .swap(true, std::sync::atomic::Ordering::SeqCst)
1334            {
1335                return Err(RecordError::Poisoned);
1336            }
1337            self.recorded
1338                .lock()
1339                .unwrap_or_else(std::sync::PoisonError::into_inner)
1340                .push(event);
1341            Ok(RecordOutcome::Recorded)
1342        }
1343    }
1344
1345    fn request(method: &str, name: Option<&str>, body: &Value, key: &str) -> Request<Full<Bytes>> {
1346        let mut body = body.clone();
1347        if let Some(object) = body.as_object_mut() {
1348            object
1349                .entry("jsonrpc")
1350                .or_insert_with(|| Value::String("2.0".to_owned()));
1351            object
1352                .entry("method")
1353                .or_insert_with(|| Value::String(method.to_owned()));
1354        }
1355        let mut builder = Request::builder()
1356            .header(PROTOCOL_VERSION_HEADER, "2026-07-28")
1357            .header(METHOD_HEADER, method)
1358            .header(crate::API_KEY_HEADER, key);
1359        if let Some(name) = name {
1360            builder = builder.header(NAME_HEADER, name);
1361        }
1362        builder
1363            .body(Full::new(Bytes::from(body.to_string())))
1364            .unwrap()
1365    }
1366
1367    fn response(body: &Value) -> Response<Full<Bytes>> {
1368        Response::builder()
1369            .header(CONTENT_TYPE, "application/json")
1370            .body(Full::new(Bytes::from(serde_json::to_vec(&body).unwrap())))
1371            .unwrap()
1372    }
1373
1374    async fn consume(response: Response<MeterBody>) -> Bytes {
1375        response.into_body().collect().await.unwrap().to_bytes()
1376    }
1377
1378    /// Release a body the way a transport does once `Content-Length` is met:
1379    /// take the data, then drop without ever polling to end-of-stream.
1380    fn release_without_end_of_stream(response: Response<MeterBody>) {
1381        let mut body = Box::pin(response.into_body());
1382        let mut context = Context::from_waker(std::task::Waker::noop());
1383        let _ = body.as_mut().poll_frame(&mut context);
1384        drop(body);
1385    }
1386
1387    /// A future that pends exactly once, the way a store doing I/O behaves.
1388    struct YieldOnce(bool);
1389
1390    impl Future for YieldOnce {
1391        type Output = ();
1392        fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<()> {
1393            if self.0 {
1394                Poll::Ready(())
1395            } else {
1396                self.0 = true;
1397                context.waker().wake_by_ref();
1398                Poll::Pending
1399            }
1400        }
1401    }
1402
1403    /// A task store that yields before answering, standing in for Redis or
1404    /// `PostgreSQL` without requiring either to be running.
1405    #[derive(Debug, Default)]
1406    struct YieldingTaskStore(InMemoryTaskStore);
1407
1408    impl TaskAttributionStore for YieldingTaskStore {
1409        fn insert<'a>(
1410            &'a self,
1411            tenant_id: &'a str,
1412            task_id: &'a str,
1413            attribution: TaskAttribution,
1414        ) -> crate::TaskStoreFuture<'a, ()> {
1415            Box::pin(async move {
1416                YieldOnce(false).await;
1417                self.0.insert(tenant_id, task_id, attribution).await
1418            })
1419        }
1420        fn get<'a>(
1421            &'a self,
1422            tenant_id: &'a str,
1423            task_id: &'a str,
1424        ) -> crate::TaskStoreFuture<'a, Option<TaskAttribution>> {
1425            Box::pin(async move {
1426                YieldOnce(false).await;
1427                self.0.get(tenant_id, task_id).await
1428            })
1429        }
1430        fn claim<'a>(
1431            &'a self,
1432            tenant_id: &'a str,
1433            task_id: &'a str,
1434        ) -> crate::TaskStoreFuture<'a, Option<TaskAttribution>> {
1435            Box::pin(async move {
1436                YieldOnce(false).await;
1437                self.0.claim(tenant_id, task_id).await
1438            })
1439        }
1440        fn remove<'a>(
1441            &'a self,
1442            tenant_id: &'a str,
1443            task_id: &'a str,
1444        ) -> crate::TaskStoreFuture<'a, ()> {
1445            Box::pin(async move {
1446                YieldOnce(false).await;
1447                self.0.remove(tenant_id, task_id).await
1448            })
1449        }
1450    }
1451
1452    /// Drive a `tools/call` that creates a task, then a `tasks/get` that reports
1453    /// it completed, releasing both bodies the way a real transport does.
1454    async fn run_task_lifecycle<S>(service: S)
1455    where
1456        S: tower::Service<Request<Full<Bytes>>, Response = Response<MeterBody>, Error = Infallible>
1457            + Clone
1458            + Send
1459            + 'static,
1460        S::Future: Send,
1461    {
1462        release_without_end_of_stream(
1463            service
1464                .clone()
1465                .oneshot(request(
1466                    "tools/call",
1467                    Some("long_job"),
1468                    &json!({"id":1,"params":{"name":"long_job"}}),
1469                    "secret",
1470                ))
1471                .await
1472                .unwrap(),
1473        );
1474        release_without_end_of_stream(
1475            service
1476                .oneshot(request(
1477                    "tasks/get",
1478                    None,
1479                    &json!({"id":2,"params":{"taskId":"task-1"}}),
1480                    "secret",
1481                ))
1482                .await
1483                .unwrap(),
1484        );
1485    }
1486
1487    fn task_lifecycle_service(
1488        config: EdgeConfig,
1489    ) -> impl tower::Service<
1490        Request<Full<Bytes>>,
1491        Response = Response<MeterBody>,
1492        Error = Infallible,
1493        Future = impl Send,
1494    > + Clone
1495    + Send
1496    + 'static {
1497        let stage = Arc::new(std::sync::atomic::AtomicU64::new(0));
1498        ServiceBuilder::new()
1499            .layer(MeterLayer::new(config))
1500            .service(service_fn(move |_request: Request<Full<Bytes>>| {
1501                let n = stage.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1502                async move {
1503                    let result = if n == 0 {
1504                        json!({"resultType":"task","taskId":"task-1","status":"working"})
1505                    } else {
1506                        json!({"resultType":"complete","taskId":"task-1","status":"completed","result":{}})
1507                    };
1508                    Ok::<_, Infallible>(response(&json!({"jsonrpc":"2.0","id":n,"result":result})))
1509                }
1510            }))
1511    }
1512
1513    fn task_tenants() -> Arc<InMemoryTenantStore> {
1514        let tenants = Arc::new(InMemoryTenantStore::new());
1515        tenants.insert_unchecked(
1516            "secret",
1517            Tenant::new("acme", "cus_acme")
1518                .with_prices(PriceBook::flat(1).with_name("long_job", 50)),
1519        );
1520        tenants
1521    }
1522
1523    #[tokio::test]
1524    async fn a_store_that_yields_is_parked_rather_than_abandoned() {
1525        // A durable store performs I/O, so its future pends on the single poll
1526        // `Drop` can give it. Before this was parked, every durable-task charge
1527        // was lost: the attribution insert never landed, so the completing poll
1528        // had nothing to price.
1529        let config = EdgeConfig::new(task_tenants())
1530            .with_task_store(Arc::new(YieldingTaskStore::default()))
1531            .with_deferred_drain_per_request(0);
1532        let metrics = config.metrics();
1533        let deferred = config.deferred();
1534
1535        run_task_lifecycle(task_lifecycle_service(config)).await;
1536
1537        assert_eq!(
1538            metrics.snapshot().billed,
1539            0,
1540            "nothing can be billed while the store has not answered"
1541        );
1542        assert!(
1543            !deferred.is_empty(),
1544            "the unfinished accounting must be parked"
1545        );
1546        assert_eq!(deferred.dropped(), 0);
1547
1548        // Draining is the context that can await, so this is where it lands.
1549        assert!(deferred.drain().await > 0);
1550
1551        let snapshot = metrics.snapshot();
1552        assert_eq!(snapshot.billed, 1, "the completed task must be charged");
1553        assert_eq!(
1554            snapshot.billed_units, 50,
1555            "priced from the originating call"
1556        );
1557        assert_eq!(snapshot.record_failures, 0);
1558        assert!(deferred.is_empty());
1559    }
1560
1561    #[tokio::test]
1562    async fn a_later_request_drains_parked_accounting_on_its_own() {
1563        // An application that never calls `drain` must still converge, so every
1564        // request runs a bounded amount of parked work.
1565        let config =
1566            EdgeConfig::new(task_tenants()).with_task_store(Arc::new(YieldingTaskStore::default()));
1567        let metrics = config.metrics();
1568        let deferred = config.deferred();
1569        let service = task_lifecycle_service(config);
1570
1571        run_task_lifecycle(service.clone()).await;
1572        assert_eq!(metrics.snapshot().billed, 0);
1573
1574        // Ordinary unrelated traffic, with no drain call anywhere.
1575        for id in 3..8 {
1576            release_without_end_of_stream(
1577                service
1578                    .clone()
1579                    .oneshot(request(
1580                        "tools/list",
1581                        None,
1582                        &json!({"id":id,"params":{}}),
1583                        "secret",
1584                    ))
1585                    .await
1586                    .unwrap(),
1587            );
1588        }
1589
1590        assert_eq!(
1591            metrics.snapshot().billed,
1592            1,
1593            "subsequent requests must complete the parked accounting"
1594        );
1595        assert_eq!(metrics.snapshot().billed_units, 50);
1596        assert!(deferred.is_empty());
1597    }
1598
1599    #[tokio::test]
1600    async fn malformed_and_unauthenticated_requests_do_not_poll_deferred_work() {
1601        let tenants = Arc::new(InMemoryTenantStore::new());
1602        tenants.insert_unchecked("secret", Tenant::new("acme", "cus_acme"));
1603        let config = EdgeConfig::new(tenants);
1604        let deferred = config.deferred();
1605        let polled = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1606        assert!(deferred.park(Box::pin({
1607            let polled = Arc::clone(&polled);
1608            async move {
1609                polled.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1610            }
1611        })));
1612        let service = ServiceBuilder::new()
1613            .layer(MeterLayer::new(config))
1614            .service(service_fn(|_request: Request<Full<Bytes>>| async {
1615                Ok::<_, Infallible>(response(&json!({
1616                    "jsonrpc":"2.0","id":1,"result":{"resultType":"complete"}
1617                })))
1618            }));
1619
1620        let mut malformed = request("tools/list", None, &json!({"id":1,"params":{}}), "secret");
1621        malformed.headers_mut().insert(
1622            PROTOCOL_VERSION_HEADER,
1623            http::HeaderValue::from_static("invalid"),
1624        );
1625        assert_eq!(
1626            service.clone().oneshot(malformed).await.unwrap().status(),
1627            StatusCode::BAD_REQUEST
1628        );
1629        assert_eq!(polled.load(std::sync::atomic::Ordering::Relaxed), 0);
1630
1631        assert_eq!(
1632            service
1633                .clone()
1634                .oneshot(request(
1635                    "tools/list",
1636                    None,
1637                    &json!({"id":2,"params":{}}),
1638                    "wrong",
1639                ))
1640                .await
1641                .unwrap()
1642                .status(),
1643            StatusCode::UNAUTHORIZED
1644        );
1645        assert_eq!(polled.load(std::sync::atomic::Ordering::Relaxed), 0);
1646        assert_eq!(deferred.len(), 1);
1647
1648        consume(
1649            service
1650                .oneshot(request(
1651                    "tools/list",
1652                    None,
1653                    &json!({"id":3,"params":{}}),
1654                    "secret",
1655                ))
1656                .await
1657                .unwrap(),
1658        )
1659        .await;
1660        assert_eq!(polled.load(std::sync::atomic::Ordering::Relaxed), 1);
1661        assert!(deferred.is_empty());
1662    }
1663
1664    #[tokio::test]
1665    async fn recorder_rejection_restores_claimed_task_origin_for_a_later_poll() {
1666        let tasks = Arc::new(InMemoryTaskStore::new());
1667        let recorder = Arc::new(RejectOnceRecorder::default());
1668        let metrics_config = EdgeConfig::new(task_tenants())
1669            .with_task_store(tasks.clone())
1670            .with_recorder(recorder.clone());
1671        let metrics = metrics_config.metrics();
1672        let service = task_lifecycle_service(metrics_config);
1673
1674        release_without_end_of_stream(
1675            service
1676                .clone()
1677                .oneshot(request(
1678                    "tools/call",
1679                    Some("long_job"),
1680                    &json!({"id":1,"params":{"name":"long_job"}}),
1681                    "secret",
1682                ))
1683                .await
1684                .unwrap(),
1685        );
1686        release_without_end_of_stream(
1687            service
1688                .clone()
1689                .oneshot(request(
1690                    "tasks/get",
1691                    None,
1692                    &json!({"id":2,"params":{"taskId":"task-1"}}),
1693                    "secret",
1694                ))
1695                .await
1696                .unwrap(),
1697        );
1698        assert_eq!(
1699            tasks.len(),
1700            1,
1701            "the rejected charge must restore its origin"
1702        );
1703        assert!(recorder.recorded.lock().unwrap().is_empty());
1704
1705        release_without_end_of_stream(
1706            service
1707                .oneshot(request(
1708                    "tasks/get",
1709                    None,
1710                    &json!({"id":3,"params":{"taskId":"task-1"}}),
1711                    "secret",
1712                ))
1713                .await
1714                .unwrap(),
1715        );
1716        assert!(tasks.is_empty());
1717        let captured = recorder.recorded.lock().unwrap();
1718        assert_eq!(captured.len(), 1);
1719        assert_eq!(captured[0].units, 50);
1720        let snapshot = metrics.snapshot();
1721        assert_eq!(snapshot.billed, 1);
1722        assert_eq!(snapshot.record_failures, 1);
1723    }
1724
1725    #[tokio::test]
1726    async fn a_free_verdict_puts_back_the_attribution_it_claimed() {
1727        // The claim deletes the attribution before the verdict is known, on
1728        // the assumption that a completed task bills. A `tasks/get` answering
1729        // with `resultType: "task"` reads as a task creation and comes back
1730        // free, so without a restore the charge is destroyed permanently and
1731        // every later poll reports a missing attribution.
1732        let tasks = Arc::new(InMemoryTaskStore::new());
1733        let config = EdgeConfig::new(task_tenants()).with_task_store(tasks.clone());
1734        let stage = Arc::new(std::sync::atomic::AtomicU64::new(0));
1735        let service = ServiceBuilder::new()
1736            .layer(MeterLayer::new(config))
1737            .service(service_fn(move |_request: Request<Full<Bytes>>| {
1738                let n = stage.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1739                async move {
1740                    let result = match n {
1741                        0 => json!({"resultType":"task","taskId":"task-1","status":"working"}),
1742                        // The odd shape: a task object, reported as one.
1743                        1 => json!({"resultType":"task","taskId":"task-1","status":"completed"}),
1744                        _ => json!({
1745                            "resultType":"complete",
1746                            "taskId":"task-1",
1747                            "status":"completed",
1748                            "result":{}
1749                        }),
1750                    };
1751                    Ok::<_, Infallible>(response(&json!({
1752                        "jsonrpc":"2.0","id":n,"result":result
1753                    })))
1754                }
1755            }));
1756
1757        release_without_end_of_stream(
1758            service
1759                .clone()
1760                .oneshot(request(
1761                    "tools/call",
1762                    Some("long_job"),
1763                    &json!({"id":1,"params":{"name":"long_job"}}),
1764                    "secret",
1765                ))
1766                .await
1767                .unwrap(),
1768        );
1769        assert_eq!(tasks.len(), 1, "the creation stored an attribution");
1770
1771        release_without_end_of_stream(
1772            service
1773                .clone()
1774                .oneshot(request(
1775                    "tasks/get",
1776                    None,
1777                    &json!({"id":2,"params":{"taskId":"task-1"}}),
1778                    "secret",
1779                ))
1780                .await
1781                .unwrap(),
1782        );
1783        assert_eq!(
1784            tasks.len(),
1785            1,
1786            "a free verdict must not consume the attribution"
1787        );
1788
1789        // And the charge is still collectable on a poll the meter understands.
1790        release_without_end_of_stream(
1791            service
1792                .clone()
1793                .oneshot(request(
1794                    "tasks/get",
1795                    None,
1796                    &json!({"id":3,"params":{"taskId":"task-1"}}),
1797                    "secret",
1798                ))
1799                .await
1800                .unwrap(),
1801        );
1802        assert!(tasks.is_empty(), "the completing poll consumed it");
1803    }
1804
1805    #[tokio::test]
1806    async fn an_idle_legacy_client_is_not_billed_for_lifecycle_traffic() {
1807        // A legacy request carries no protocol version, so it is classified
1808        // from its body. Promoting every bare `result` to a delivery bills
1809        // `ping` and `initialize`, which a connected client sends on a timer
1810        // forever while doing no work at all. A modern client on identical
1811        // traffic is billed nothing, so the bill is not even consistent.
1812        let tenants = Arc::new(InMemoryTenantStore::new());
1813        tenants.insert_unchecked("secret", Tenant::new("acme", "cus_acme"));
1814        let billing = Arc::new(BillingPipeline::new(CaptureExporter::default()));
1815        let config = EdgeConfig::new(tenants).with_recorder(billing.clone());
1816        let metrics = config.metrics();
1817        let service = ServiceBuilder::new()
1818            .layer(MeterLayer::new(config))
1819            .service(service_fn(|_request: Request<Full<Bytes>>| async {
1820                Ok::<_, Infallible>(response(&json!({"jsonrpc":"2.0","id":1,"result":{}})))
1821            }));
1822
1823        for method in [
1824            "ping",
1825            "initialize",
1826            "logging/setLevel",
1827            "resources/subscribe",
1828        ] {
1829            let legacy = Request::builder()
1830                .method("POST")
1831                .header(crate::API_KEY_HEADER, "secret")
1832                .body(Full::new(Bytes::from(
1833                    json!({"jsonrpc":"2.0","id":1,"method":method}).to_string(),
1834                )))
1835                .unwrap();
1836            release_without_end_of_stream(service.clone().oneshot(legacy).await.unwrap());
1837        }
1838
1839        assert_eq!(
1840            metrics.snapshot().billed,
1841            0,
1842            "lifecycle traffic must not be billed on the legacy path"
1843        );
1844    }
1845
1846    #[tokio::test]
1847    async fn a_legacy_tool_call_is_still_billed_after_scoping_the_promotion() {
1848        // The regression guard for the fix: scoping the legacy promotion must
1849        // not make legacy tool calls free, which is what it exists to prevent.
1850        let tenants = Arc::new(InMemoryTenantStore::new());
1851        tenants.insert_unchecked(
1852            "secret",
1853            Tenant::new("acme", "cus_acme").with_prices(PriceBook::flat(4)),
1854        );
1855        let billing = Arc::new(BillingPipeline::new(CaptureExporter::default()));
1856        let config = EdgeConfig::new(tenants).with_recorder(billing.clone());
1857        let metrics = config.metrics();
1858        let service = ServiceBuilder::new()
1859            .layer(MeterLayer::new(config))
1860            .service(service_fn(|_request: Request<Full<Bytes>>| async {
1861                // No resultType at all, which is the shape the promotion is for.
1862                Ok::<_, Infallible>(response(&json!({"jsonrpc":"2.0","id":1,"result":{}})))
1863            }));
1864
1865        let legacy = Request::builder()
1866            .method("POST")
1867            .header(crate::API_KEY_HEADER, "secret")
1868            .body(Full::new(Bytes::from(
1869                json!({"jsonrpc":"2.0","id":1,"method":"tools/call",
1870                       "params":{"name":"search"}})
1871                .to_string(),
1872            )))
1873            .unwrap();
1874        release_without_end_of_stream(service.oneshot(legacy).await.unwrap());
1875
1876        assert_eq!(metrics.snapshot().billed, 1);
1877    }
1878
1879    #[tokio::test]
1880    async fn missing_completed_task_id_preserves_origin_for_a_later_valid_poll() {
1881        let tasks = Arc::new(InMemoryTaskStore::new());
1882        let config = EdgeConfig::new(task_tenants()).with_task_store(tasks.clone());
1883        let metrics = config.metrics();
1884        let stage = Arc::new(std::sync::atomic::AtomicU64::new(0));
1885        let service = ServiceBuilder::new()
1886            .layer(MeterLayer::new(config))
1887            .service(service_fn(move |_request: Request<Full<Bytes>>| {
1888                let n = stage.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1889                async move {
1890                    let result = match n {
1891                        0 => json!({
1892                            "resultType":"task",
1893                            "taskId":"task-1",
1894                            "status":"working"
1895                        }),
1896                        1 => json!({
1897                            "resultType":"complete",
1898                            "status":"completed",
1899                            "result":{}
1900                        }),
1901                        _ => json!({
1902                            "resultType":"complete",
1903                            "taskId":"task-1",
1904                            "status":"completed",
1905                            "result":{}
1906                        }),
1907                    };
1908                    Ok::<_, Infallible>(response(&json!({
1909                        "jsonrpc":"2.0",
1910                        "id":n,
1911                        "result":result
1912                    })))
1913                }
1914            }));
1915
1916        release_without_end_of_stream(
1917            service
1918                .clone()
1919                .oneshot(request(
1920                    "tools/call",
1921                    Some("long_job"),
1922                    &json!({"id":1,"params":{"name":"long_job"}}),
1923                    "secret",
1924                ))
1925                .await
1926                .unwrap(),
1927        );
1928        release_without_end_of_stream(
1929            service
1930                .clone()
1931                .oneshot(request(
1932                    "tasks/get",
1933                    None,
1934                    &json!({"id":2,"params":{"taskId":"task-1"}}),
1935                    "secret",
1936                ))
1937                .await
1938                .unwrap(),
1939        );
1940
1941        assert_eq!(
1942            tasks.len(),
1943            1,
1944            "the malformed poll must not consume the origin"
1945        );
1946        assert_eq!(metrics.snapshot().billed, 0);
1947
1948        release_without_end_of_stream(
1949            service
1950                .oneshot(request(
1951                    "tasks/get",
1952                    None,
1953                    &json!({"id":3,"params":{"taskId":"task-1"}}),
1954                    "secret",
1955                ))
1956                .await
1957                .unwrap(),
1958        );
1959
1960        assert!(tasks.is_empty());
1961        let snapshot = metrics.snapshot();
1962        assert_eq!(snapshot.billed, 1);
1963        assert_eq!(snapshot.billed_units, 50);
1964    }
1965
1966    #[tokio::test]
1967    async fn free_verdicts_reach_the_metrics_under_their_own_reason() {
1968        // The layer used to throw the reason away, which is the one thing the
1969        // enum exists to carry. Discovery traffic is normal; a completed task
1970        // that cannot be joined to its origin is lost revenue. Collapsed into
1971        // a single `free` counter the two are indistinguishable, so an
1972        // operator watching the dashboard cannot see the second happening.
1973        let config = EdgeConfig::new(task_tenants());
1974        let metrics = config.metrics();
1975        let service = ServiceBuilder::new()
1976            .layer(MeterLayer::new(config))
1977            .service(service_fn(|_request: Request<Full<Bytes>>| async move {
1978                Ok::<_, Infallible>(response(&json!({
1979                    "jsonrpc": "2.0",
1980                    "id": 1,
1981                    "result": {
1982                        "resultType": "complete",
1983                        "taskId": "task-unknown",
1984                        "status": "completed",
1985                        "content": []
1986                    }
1987                })))
1988            }));
1989
1990        // Discovery: free, and unremarkable.
1991        consume(
1992            service
1993                .clone()
1994                .oneshot(request("tools/list", None, &json!({"id":1}), "secret"))
1995                .await
1996                .unwrap(),
1997        )
1998        .await;
1999
2000        // A completed task nobody claimed: free, and a lost charge.
2001        consume(
2002            service
2003                .oneshot(request(
2004                    "tasks/get",
2005                    None,
2006                    &json!({"id":2,"params":{"taskId":"task-unknown"}}),
2007                    "secret",
2008                ))
2009                .await
2010                .unwrap(),
2011        )
2012        .await;
2013
2014        let snapshot = metrics.snapshot();
2015        assert_eq!(snapshot.billed, 0);
2016        assert_eq!(snapshot.free, 2);
2017        assert_eq!(
2018            snapshot.free_by_reason[FreeReason::Discovery.index()],
2019            1,
2020            "the listing must land under discovery"
2021        );
2022        assert_eq!(
2023            snapshot.free_by_reason[FreeReason::MissingTaskAttribution.index()],
2024            1,
2025            "the orphaned completion must be separable from discovery"
2026        );
2027    }
2028
2029    #[tokio::test]
2030    async fn a_synchronous_store_never_parks_anything() {
2031        // The default in-process store answers without yielding, so the common
2032        // deployment must not pay for the queue at all.
2033        let config = EdgeConfig::new(task_tenants());
2034        let metrics = config.metrics();
2035        let deferred = config.deferred();
2036
2037        run_task_lifecycle(task_lifecycle_service(config)).await;
2038
2039        let snapshot = metrics.snapshot();
2040        assert_eq!(snapshot.billed, 1);
2041        assert_eq!(snapshot.billed_units, 50);
2042        assert_eq!(snapshot.deferred, 0, "nothing should have been parked");
2043        assert!(deferred.is_empty());
2044    }
2045
2046    /// A legacy client sends no mirrored headers at all. Before the body fallback the
2047    /// edge refused it outright, which shut out every client older than the mirrored
2048    /// headers - including the default configuration of MCP Inspector.
2049    #[tokio::test]
2050    async fn a_legacy_client_is_metered_from_its_body() {
2051        let tenants = Arc::new(InMemoryTenantStore::new());
2052        tenants.insert_unchecked(
2053            "secret",
2054            Tenant::new("acme", "cus_acme")
2055                .with_prices(PriceBook::flat(1).with_name("expensive", 25)),
2056        );
2057        let billing = Arc::new(BillingPipeline::new(CaptureExporter::default()));
2058        let config = EdgeConfig::new(tenants).with_recorder(billing.clone());
2059        let service = ServiceBuilder::new()
2060            .layer(MeterLayer::new(config))
2061            .service(service_fn(|_request: Request<Full<Bytes>>| async {
2062                Ok::<_, Infallible>(response(&json!({
2063                    "jsonrpc":"2.0","id":1,
2064                    "result":{"resultType":"complete","content":[]}
2065                })))
2066            }));
2067
2068        // No MCP-Protocol-Version, no Mcp-Method, no Mcp-Name: only a JSON-RPC body.
2069        let legacy = Request::builder()
2070            .method("POST")
2071            .header(crate::API_KEY_HEADER, "secret")
2072            .body(Full::new(Bytes::from(
2073                json!({"jsonrpc":"2.0","id":1,"method":"tools/call",
2074                       "params":{"name":"expensive"}})
2075                .to_string(),
2076            )))
2077            .unwrap();
2078
2079        let response = service.oneshot(legacy).await.unwrap();
2080        assert_eq!(response.status(), StatusCode::OK);
2081        consume(response).await;
2082        billing.flush().await.unwrap();
2083        assert_eq!(
2084            billing.exporter().exported()[0].units,
2085            25,
2086            "the body named the expensive tool, so the expensive price applies"
2087        );
2088    }
2089
2090    /// The trap the fallback opens if nobody looks: a legacy server answers without a
2091    /// `resultType`, which the charge logic reads as unrecognized and bills as free.
2092    /// Admitting legacy clients without this would have handed out unlimited free usage.
2093    #[tokio::test]
2094    async fn a_legacy_response_without_a_result_type_is_still_billed() {
2095        let tenants = Arc::new(InMemoryTenantStore::new());
2096        tenants.insert_unchecked(
2097            "secret",
2098            Tenant::new("acme", "cus_acme").with_prices(PriceBook::flat(7)),
2099        );
2100        let billing = Arc::new(BillingPipeline::new(CaptureExporter::default()));
2101        let config = EdgeConfig::new(tenants).with_recorder(billing.clone());
2102        let service = ServiceBuilder::new()
2103            .layer(MeterLayer::new(config))
2104            .service(service_fn(|_request: Request<Full<Bytes>>| async {
2105                // No resultType: exactly what a server answering a legacy client sends.
2106                Ok::<_, Infallible>(response(&json!({
2107                    "jsonrpc":"2.0","id":1,"result":{"content":[]}
2108                })))
2109            }));
2110
2111        let legacy = Request::builder()
2112            .method("POST")
2113            .header(crate::API_KEY_HEADER, "secret")
2114            .body(Full::new(Bytes::from(
2115                json!({"jsonrpc":"2.0","id":1,"method":"tools/call",
2116                       "params":{"name":"anything"}})
2117                .to_string(),
2118            )))
2119            .unwrap();
2120
2121        let response = service.oneshot(legacy).await.unwrap();
2122        consume(response).await;
2123        billing.flush().await.unwrap();
2124        assert_eq!(
2125            billing.exporter().exported()[0].units,
2126            7,
2127            "a legacy result is delivered work and must be billed"
2128        );
2129    }
2130
2131    /// The same absence on a modern response must stay free: there it means the meter
2132    /// did not understand the result, not that work was delivered.
2133    #[tokio::test]
2134    async fn a_modern_response_without_a_result_type_is_still_free() {
2135        let tenants = Arc::new(InMemoryTenantStore::new());
2136        tenants.insert_unchecked(
2137            "secret",
2138            Tenant::new("acme", "cus_acme").with_prices(PriceBook::flat(7)),
2139        );
2140        let billing = Arc::new(BillingPipeline::new(CaptureExporter::default()));
2141        let config = EdgeConfig::new(tenants).with_recorder(billing.clone());
2142        let service = ServiceBuilder::new()
2143            .layer(MeterLayer::new(config))
2144            .service(service_fn(|_request: Request<Full<Bytes>>| async {
2145                Ok::<_, Infallible>(response(&json!({
2146                    "jsonrpc":"2.0","id":1,"result":{"content":[]}
2147                })))
2148            }));
2149
2150        let response = service
2151            .oneshot(request(
2152                "tools/call",
2153                Some("anything"),
2154                &json!({"id":1,"params":{"name":"anything"}}),
2155                "secret",
2156            ))
2157            .await
2158            .unwrap();
2159        consume(response).await;
2160        billing.flush().await.unwrap();
2161        assert!(
2162            billing.exporter().exported().is_empty(),
2163            "an unrecognized modern result must not be billed"
2164        );
2165    }
2166
2167    /// The reason pricing on a legacy body is safe, stated as a test: the name is read
2168    /// from the document the origin executes. Claiming a cheap tool in the only place
2169    /// the meter looks means calling the cheap tool.
2170    #[tokio::test]
2171    async fn a_legacy_client_cannot_underpay_by_lying_about_the_tool() {
2172        let tenants = Arc::new(InMemoryTenantStore::new());
2173        tenants.insert_unchecked(
2174            "secret",
2175            Tenant::new("acme", "cus_acme")
2176                .with_prices(PriceBook::flat(1).with_name("expensive", 25)),
2177        );
2178        let billing = Arc::new(BillingPipeline::new(CaptureExporter::default()));
2179        let config = EdgeConfig::new(tenants).with_recorder(billing.clone());
2180        let seen: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
2181        let observed = seen.clone();
2182        let service = ServiceBuilder::new()
2183            .layer(MeterLayer::new(config))
2184            .service(service_fn(move |request: Request<Full<Bytes>>| {
2185                let observed = observed.clone();
2186                async move {
2187                    let bytes = request.into_body().collect().await.unwrap().to_bytes();
2188                    let body: Value = serde_json::from_slice(&bytes).unwrap();
2189                    *observed.lock().unwrap() =
2190                        Some(body["params"]["name"].as_str().unwrap().into());
2191                    Ok::<_, Infallible>(response(&json!({
2192                        "jsonrpc":"2.0","id":1,
2193                        "result":{"resultType":"complete","content":[]}
2194                    })))
2195                }
2196            }));
2197
2198        // Mirrored headers claim the cheap call; the body asks for the expensive one.
2199        // On a trusted revision the origin would reject the disagreement. Here the
2200        // headers are ignored entirely, so the body decides both price and execution.
2201        let lying = Request::builder()
2202            .method("POST")
2203            .header(crate::API_KEY_HEADER, "secret")
2204            .header(METHOD_HEADER, "tools/list")
2205            .header(NAME_HEADER, "cheap")
2206            .body(Full::new(Bytes::from(
2207                json!({"jsonrpc":"2.0","id":1,"method":"tools/call",
2208                       "params":{"name":"expensive"}})
2209                .to_string(),
2210            )))
2211            .unwrap();
2212
2213        let response = service.oneshot(lying).await.unwrap();
2214        assert_eq!(response.status(), StatusCode::OK);
2215        consume(response).await;
2216        billing.flush().await.unwrap();
2217        assert_eq!(
2218            billing.exporter().exported()[0].units,
2219            25,
2220            "billed for what the origin ran, not what the headers claimed"
2221        );
2222        assert_eq!(
2223            seen.lock().unwrap().as_deref(),
2224            Some("expensive"),
2225            "the origin ran the tool the body named"
2226        );
2227    }
2228
2229    /// Strict deployments keep the old behaviour.
2230    #[tokio::test]
2231    async fn strict_mode_still_refuses_legacy_clients() {
2232        let tenants = Arc::new(InMemoryTenantStore::new());
2233        tenants.insert_unchecked("secret", Tenant::new("acme", "cus_acme"));
2234        let config = EdgeConfig::new(tenants).with_strict_protocol_version(true);
2235        let service = ServiceBuilder::new()
2236            .layer(MeterLayer::new(config))
2237            .service(service_fn(|_request: Request<Full<Bytes>>| async {
2238                Ok::<_, Infallible>(response(&json!({
2239                    "jsonrpc":"2.0","id":1,"result":{"resultType":"complete"}
2240                })))
2241            }));
2242
2243        let legacy = Request::builder()
2244            .method("POST")
2245            .header(crate::API_KEY_HEADER, "secret")
2246            .body(Full::new(Bytes::from(
2247                json!({"jsonrpc":"2.0","id":1,"method":"tools/list"}).to_string(),
2248            )))
2249            .unwrap();
2250        assert_eq!(
2251            service.oneshot(legacy).await.unwrap().status(),
2252            StatusCode::BAD_REQUEST
2253        );
2254    }
2255
2256    /// The fallback must not become a way to spend server CPU without a credential.
2257    #[tokio::test]
2258    async fn an_unauthenticated_legacy_request_is_refused_before_its_body_is_read() {
2259        let tenants = Arc::new(InMemoryTenantStore::new());
2260        tenants.insert_unchecked("secret", Tenant::new("acme", "cus_acme"));
2261        let config = EdgeConfig::new(tenants);
2262        let service = ServiceBuilder::new()
2263            .layer(MeterLayer::new(config))
2264            .service(service_fn(|_request: Request<Full<Bytes>>| async {
2265                Ok::<_, Infallible>(response(&json!({
2266                    "jsonrpc":"2.0","id":1,"result":{"resultType":"complete"}
2267                })))
2268            }));
2269
2270        // Unparseable body, no credential: the credential gate must answer first.
2271        let anonymous = Request::builder()
2272            .body(Full::new(Bytes::from_static(b"{{{ not json")))
2273            .unwrap();
2274        assert_eq!(
2275            service.oneshot(anonymous).await.unwrap().status(),
2276            StatusCode::UNAUTHORIZED,
2277            "an anonymous caller must not reach the JSON parser"
2278        );
2279    }
2280
2281    #[tokio::test]
2282    async fn complete_tool_call_is_recorded_after_body_consumption() {
2283        let tenants = Arc::new(InMemoryTenantStore::new());
2284        tenants.insert_unchecked(
2285            "secret",
2286            Tenant::new("acme", "cus_acme")
2287                .with_prices(PriceBook::flat(1).with_name("expensive", 25)),
2288        );
2289        let billing = Arc::new(BillingPipeline::new(CaptureExporter::default()));
2290        let config = EdgeConfig::new(tenants).with_recorder(billing.clone());
2291        let service = ServiceBuilder::new()
2292            .layer(MeterLayer::new(config))
2293            .service(service_fn(|_request: Request<Full<Bytes>>| async {
2294                Ok::<_, Infallible>(response(&json!({
2295                    "jsonrpc":"2.0","id":1,
2296                    "result":{"resultType":"complete","content":[]}
2297                })))
2298            }));
2299
2300        let response = service
2301            .oneshot(request(
2302                "tools/call",
2303                Some("expensive"),
2304                &json!({"jsonrpc":"2.0","id":1,"params":{"name":"expensive"}}),
2305                "secret",
2306            ))
2307            .await
2308            .unwrap();
2309        assert_eq!(billing.pending_buckets(), 0, "not billed before delivery");
2310        consume(response).await;
2311        assert_eq!(billing.pending_buckets(), 1);
2312        billing.flush().await.unwrap();
2313        assert_eq!(billing.exporter().exported()[0].units, 25);
2314    }
2315
2316    #[tokio::test]
2317    async fn private_cache_never_crosses_authorization_contexts_and_rewrites_response_id() {
2318        let tenants = Arc::new(InMemoryTenantStore::new());
2319        tenants.insert_unchecked("key-a", Tenant::new("a", "cus_a"));
2320        // Even two credentials mapped to the same tenant are distinct
2321        // authorization contexts for private-cache purposes.
2322        tenants.insert_unchecked("key-b", Tenant::new("a", "cus_a"));
2323        let calls = Arc::new(std::sync::atomic::AtomicU64::new(0));
2324        let inner_calls = calls.clone();
2325        let config = EdgeConfig::new(tenants);
2326        let service = ServiceBuilder::new()
2327            .layer(MeterLayer::new(config))
2328            .service(service_fn(move |_request: Request<Full<Bytes>>| {
2329                inner_calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2330                async {
2331                    Ok::<_, Infallible>(response(&json!({
2332                        "jsonrpc":"2.0","id":1,
2333                        "result":{"resultType":"complete","tools":[],"ttlMs":60000,"cacheScope":"private"}
2334                    })))
2335                }
2336            }));
2337
2338        let first = service
2339            .clone()
2340            .oneshot(request(
2341                "tools/list",
2342                None,
2343                &json!({"jsonrpc":"2.0","id":1,"params":{}}),
2344                "key-a",
2345            ))
2346            .await
2347            .unwrap();
2348        consume(first).await;
2349
2350        let hit = service
2351            .clone()
2352            .oneshot(request(
2353                "tools/list",
2354                None,
2355                &json!({"jsonrpc":"2.0","id":99,"params":{}}),
2356                "key-a",
2357            ))
2358            .await
2359            .unwrap();
2360        assert_eq!(hit.headers()["x-mcp-usage-cache"], "hit");
2361        let hit_body: Value = serde_json::from_slice(&consume(hit).await).unwrap();
2362        assert_eq!(hit_body["id"], 99);
2363
2364        let other = service
2365            .oneshot(request(
2366                "tools/list",
2367                None,
2368                &json!({"jsonrpc":"2.0","id":2,"params":{}}),
2369                "key-b",
2370            ))
2371            .await
2372            .unwrap();
2373        consume(other).await;
2374        assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 2);
2375    }
2376
2377    #[tokio::test]
2378    async fn cache_hits_for_billable_resources_are_still_metered_on_delivery() {
2379        let tenants = Arc::new(InMemoryTenantStore::new());
2380        tenants.insert_unchecked(
2381            "secret",
2382            Tenant::new("acme", "cus_acme")
2383                .with_prices(PriceBook::flat(1).with_name("file:///report", 4)),
2384        );
2385        let billing = Arc::new(BillingPipeline::new(CaptureExporter::default()));
2386        let calls = Arc::new(std::sync::atomic::AtomicU64::new(0));
2387        let inner_calls = calls.clone();
2388        let service = ServiceBuilder::new()
2389            .layer(MeterLayer::new(
2390                EdgeConfig::new(tenants).with_recorder(billing.clone()),
2391            ))
2392            .service(service_fn(move |_request: Request<Full<Bytes>>| {
2393                inner_calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2394                async {
2395                    Ok::<_, Infallible>(response(&json!({
2396                        "jsonrpc":"2.0","id":1,
2397                        "result":{
2398                            "resultType":"complete",
2399                            "contents":[],
2400                            "ttlMs":60000,
2401                            "cacheScope":"private"
2402                        }
2403                    })))
2404                }
2405            }));
2406
2407        for id in [1, 2] {
2408            consume(
2409                service
2410                    .clone()
2411                    .oneshot(request(
2412                        "resources/read",
2413                        Some("file:///report"),
2414                        &json!({"jsonrpc":"2.0","id":id,"params":{"uri":"file:///report"}}),
2415                        "secret",
2416                    ))
2417                    .await
2418                    .unwrap(),
2419            )
2420            .await;
2421        }
2422        assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 1);
2423        billing.flush().await.unwrap();
2424        assert_eq!(billing.exporter().exported()[0].units, 8);
2425    }
2426
2427    #[tokio::test]
2428    async fn trace_context_reaches_the_rmcp_service_unchanged() {
2429        let tenants = Arc::new(InMemoryTenantStore::new());
2430        tenants.insert_unchecked("secret", Tenant::new("acme", "cus_acme"));
2431        let seen = Arc::new(std::sync::atomic::AtomicBool::new(false));
2432        let inner_seen = seen.clone();
2433        let service = ServiceBuilder::new()
2434            .layer(MeterLayer::new(EdgeConfig::new(tenants)))
2435            .service(service_fn(move |request: Request<Full<Bytes>>| {
2436                inner_seen.store(
2437                    request.headers().get("traceparent").is_some_and(|value| {
2438                        value == "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
2439                    }),
2440                    std::sync::atomic::Ordering::Relaxed,
2441                );
2442                async {
2443                    Ok::<_, Infallible>(response(&json!({
2444                        "jsonrpc":"2.0","id":1,"result":{"resultType":"complete"}
2445                    })))
2446                }
2447            }));
2448        let mut request = request(
2449            "tools/call",
2450            Some("tool"),
2451            &json!({"id":1,"params":{"name":"tool"}}),
2452            "secret",
2453        );
2454        request.headers_mut().insert(
2455            "traceparent",
2456            http::HeaderValue::from_static(
2457                "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
2458            ),
2459        );
2460        consume(service.oneshot(request).await.unwrap()).await;
2461        assert!(seen.load(std::sync::atomic::Ordering::Relaxed));
2462    }
2463
2464    #[tokio::test]
2465    async fn task_completion_uses_origin_price_and_repeat_poll_is_free() {
2466        let tenants = Arc::new(InMemoryTenantStore::new());
2467        tenants.insert_unchecked(
2468            "secret",
2469            Tenant::new("acme", "cus_acme")
2470                .with_prices(PriceBook::flat(1).with_name("long_job", 50)),
2471        );
2472        let billing = Arc::new(BillingPipeline::new(CaptureExporter::default()));
2473        let stage = Arc::new(std::sync::atomic::AtomicU64::new(0));
2474        let inner_stage = stage.clone();
2475        let service = ServiceBuilder::new()
2476            .layer(MeterLayer::new(
2477                EdgeConfig::new(tenants).with_recorder(billing.clone()),
2478            ))
2479            .service(service_fn(move |_request: Request<Full<Bytes>>| {
2480                let n = inner_stage.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2481                async move {
2482                    let result = if n == 0 {
2483                        json!({"resultType":"task","taskId":"task-1","status":"working"})
2484                    } else {
2485                        json!({"resultType":"complete","taskId":"task-1","status":"completed","result":{}})
2486                    };
2487                    Ok::<_, Infallible>(response(&json!({"jsonrpc":"2.0","id":n,"result":result})))
2488                }
2489            }));
2490
2491        consume(
2492            service
2493                .clone()
2494                .oneshot(request(
2495                    "tools/call",
2496                    Some("long_job"),
2497                    &json!({"id":1,"params":{"name":"long_job"}}),
2498                    "secret",
2499                ))
2500                .await
2501                .unwrap(),
2502        )
2503        .await;
2504        for id in [2, 3] {
2505            consume(
2506                service
2507                    .clone()
2508                    .oneshot(request(
2509                        "tasks/get",
2510                        None,
2511                        &json!({"id":id,"params":{"taskId":"task-1"}}),
2512                        "secret",
2513                    ))
2514                    .await
2515                    .unwrap(),
2516            )
2517            .await;
2518        }
2519        billing.flush().await.unwrap();
2520        let exported = billing.exporter().exported();
2521        assert_eq!(exported.len(), 1);
2522        assert_eq!(exported[0].units, 50);
2523    }
2524
2525    #[tokio::test]
2526    async fn mismatched_task_response_cannot_bill_another_tasks_origin() {
2527        let tenants = Arc::new(InMemoryTenantStore::new());
2528        tenants.insert_unchecked(
2529            "secret",
2530            Tenant::new("acme", "cus_acme")
2531                .with_prices(PriceBook::flat(1).with_name("expensive", 100)),
2532        );
2533        let billing = Arc::new(BillingPipeline::new(CaptureExporter::default()));
2534        let stage = Arc::new(std::sync::atomic::AtomicU64::new(0));
2535        let inner_stage = stage.clone();
2536        let service = ServiceBuilder::new()
2537            .layer(MeterLayer::new(
2538                EdgeConfig::new(tenants).with_recorder(billing.clone()),
2539            ))
2540            .service(service_fn(move |_request: Request<Full<Bytes>>| {
2541                let n = inner_stage.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2542                async move {
2543                    let result = match n {
2544                        0 => json!({"resultType":"task","taskId":"task-1","status":"working"}),
2545                        1 => json!({"resultType":"task","taskId":"task-2","status":"working"}),
2546                        _ => json!({
2547                            "resultType":"complete",
2548                            "taskId":"task-2",
2549                            "status":"completed",
2550                            "result":{}
2551                        }),
2552                    };
2553                    Ok::<_, Infallible>(response(&json!({"jsonrpc":"2.0","id":n,"result":result})))
2554                }
2555            }));
2556
2557        for (name, id) in [("cheap", 1), ("expensive", 2)] {
2558            consume(
2559                service
2560                    .clone()
2561                    .oneshot(request(
2562                        "tools/call",
2563                        Some(name),
2564                        &json!({"id":id,"params":{"name":name}}),
2565                        "secret",
2566                    ))
2567                    .await
2568                    .unwrap(),
2569            )
2570            .await;
2571        }
2572        consume(
2573            service
2574                .oneshot(request(
2575                    "tasks/get",
2576                    None,
2577                    &json!({"id":3,"params":{"taskId":"task-1"}}),
2578                    "secret",
2579                ))
2580                .await
2581                .unwrap(),
2582        )
2583        .await;
2584        assert_eq!(billing.flush().await.unwrap(), 0);
2585    }
2586
2587    #[tokio::test]
2588    async fn accounting_runs_when_the_transport_stops_polling_early() {
2589        // A transport is not obliged to poll a body to end-of-stream. hyper
2590        // stops as soon as the declared Content-Length has been written, so
2591        // `poll_frame` never returns `Poll::Ready(None)` for an ordinary
2592        // fixed-length JSON result. Terminal accounting must survive that:
2593        // dropping the body is the only signal every transport gives us.
2594        //
2595        // This deliberately drives the body by hand rather than through
2596        // `BodyExt::collect`, which always polls to `None` and would hide the
2597        // regression this test exists to catch.
2598        let tenants = Arc::new(InMemoryTenantStore::new());
2599        tenants.insert_unchecked(
2600            "secret",
2601            Tenant::new("acme", "cus_acme").with_prices(PriceBook::flat(1).with_name("tool", 7)),
2602        );
2603        let billing = Arc::new(BillingPipeline::new(CaptureExporter::default()));
2604        let config = EdgeConfig::new(tenants).with_recorder(billing.clone());
2605        let metrics = config.metrics();
2606        let service = ServiceBuilder::new()
2607            .layer(MeterLayer::new(config))
2608            .service(service_fn(|_request: Request<Full<Bytes>>| async {
2609                Ok::<_, Infallible>(response(&json!({
2610                    "jsonrpc":"2.0","id":1,
2611                    "result":{"resultType":"complete","content":[]}
2612                })))
2613            }));
2614
2615        let response = service
2616            .oneshot(request(
2617                "tools/call",
2618                Some("tool"),
2619                &json!({"id":1,"params":{"name":"tool"}}),
2620                "secret",
2621            ))
2622            .await
2623            .unwrap();
2624
2625        let mut body = Box::pin(response.into_body());
2626        let mut context = Context::from_waker(std::task::Waker::noop());
2627        // Take the single data frame and then stop, exactly as a transport does
2628        // once it has satisfied Content-Length.
2629        assert!(
2630            matches!(
2631                body.as_mut().poll_frame(&mut context),
2632                Poll::Ready(Some(Ok(_)))
2633            ),
2634            "expected one data frame"
2635        );
2636        assert_eq!(
2637            metrics.snapshot().billed,
2638            0,
2639            "nothing should be billed before the body is released"
2640        );
2641
2642        drop(body);
2643
2644        let snapshot = metrics.snapshot();
2645        assert_eq!(
2646            snapshot.billed, 1,
2647            "the delivery must still be accounted for"
2648        );
2649        assert_eq!(snapshot.billed_units, 7);
2650        assert_eq!(snapshot.record_failures, 0);
2651        assert_eq!(billing.pending_buckets(), 1);
2652    }
2653
2654    #[tokio::test]
2655    async fn a_fully_consumed_body_is_accounted_for_exactly_once() {
2656        // The guard against the Drop path double-counting what `poll_frame`
2657        // already recorded.
2658        let tenants = Arc::new(InMemoryTenantStore::new());
2659        tenants.insert_unchecked(
2660            "secret",
2661            Tenant::new("acme", "cus_acme").with_prices(PriceBook::flat(1).with_name("tool", 5)),
2662        );
2663        let config = EdgeConfig::new(tenants);
2664        let metrics = config.metrics();
2665        let service = ServiceBuilder::new()
2666            .layer(MeterLayer::new(config))
2667            .service(service_fn(|_request: Request<Full<Bytes>>| async {
2668                Ok::<_, Infallible>(response(&json!({
2669                    "jsonrpc":"2.0","id":1,
2670                    "result":{"resultType":"complete","content":[]}
2671                })))
2672            }));
2673
2674        consume(
2675            service
2676                .oneshot(request(
2677                    "tools/call",
2678                    Some("tool"),
2679                    &json!({"id":1,"params":{"name":"tool"}}),
2680                    "secret",
2681                ))
2682                .await
2683                .unwrap(),
2684        )
2685        .await;
2686
2687        let snapshot = metrics.snapshot();
2688        assert_eq!(snapshot.billed, 1);
2689        assert_eq!(snapshot.billed_units, 5);
2690    }
2691
2692    #[tokio::test]
2693    async fn public_results_cross_tenants_only_when_sharing_is_enabled() {
2694        // Same scenario as the private-scope test above, but with the origin
2695        // declaring `cacheScope: "public"`. The origin call count is the
2696        // observable: two calls means tenant B was served from the origin, one
2697        // means it was served tenant A's cached body.
2698        for (share, expected_origin_calls) in [(false, 2), (true, 1)] {
2699            let tenants = Arc::new(InMemoryTenantStore::new());
2700            tenants.insert_unchecked("key-a", Tenant::new("a", "cus_a"));
2701            tenants.insert_unchecked("key-b", Tenant::new("b", "cus_b"));
2702            let calls = Arc::new(std::sync::atomic::AtomicU64::new(0));
2703            let inner_calls = calls.clone();
2704            let service = ServiceBuilder::new()
2705                .layer(MeterLayer::new(
2706                    EdgeConfig::new(tenants).with_public_cache_sharing(share),
2707                ))
2708                .service(service_fn(move |_request: Request<Full<Bytes>>| {
2709                    inner_calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2710                    async {
2711                        Ok::<_, Infallible>(response(&json!({
2712                            "jsonrpc":"2.0","id":1,
2713                            "result":{
2714                                "resultType":"complete","tools":[],
2715                                "ttlMs":60000,"cacheScope":"public"
2716                            }
2717                        })))
2718                    }
2719                }));
2720
2721            for key in ["key-a", "key-b"] {
2722                consume(
2723                    service
2724                        .clone()
2725                        .oneshot(request(
2726                            "tools/list",
2727                            None,
2728                            &json!({"jsonrpc":"2.0","id":1,"params":{}}),
2729                            key,
2730                        ))
2731                        .await
2732                        .unwrap(),
2733                )
2734                .await;
2735            }
2736
2737            assert_eq!(
2738                calls.load(std::sync::atomic::Ordering::Relaxed),
2739                expected_origin_calls,
2740                "with_public_cache_sharing({share})"
2741            );
2742        }
2743    }
2744
2745    #[tokio::test]
2746    async fn the_consumed_credential_does_not_reach_the_inner_service() {
2747        let tenants = Arc::new(InMemoryTenantStore::new());
2748        tenants.insert_unchecked("secret", Tenant::new("acme", "cus_acme"));
2749
2750        for (forward, expected) in [(false, false), (true, true)] {
2751            for bearer in [false, true] {
2752                let seen = Arc::new(std::sync::atomic::AtomicBool::new(false));
2753                let inner_seen = seen.clone();
2754                let service = ServiceBuilder::new()
2755                    .layer(MeterLayer::new(
2756                        EdgeConfig::new(tenants.clone()).with_credential_forwarding(forward),
2757                    ))
2758                    .service(service_fn(move |request: Request<Full<Bytes>>| {
2759                        let headers = request.headers();
2760                        inner_seen.store(
2761                            headers.contains_key(crate::API_KEY_HEADER)
2762                                || headers.contains_key(AUTHORIZATION),
2763                            std::sync::atomic::Ordering::Relaxed,
2764                        );
2765                        async {
2766                            Ok::<_, Infallible>(response(&json!({
2767                                "jsonrpc":"2.0","id":1,"result":{"resultType":"complete"}
2768                            })))
2769                        }
2770                    }));
2771
2772                let mut call = request(
2773                    "tools/call",
2774                    Some("tool"),
2775                    &json!({"id":1,"params":{"name":"tool"}}),
2776                    "secret",
2777                );
2778                if bearer {
2779                    // Exactly one credential header may be present.
2780                    call.headers_mut().remove(crate::API_KEY_HEADER);
2781                    call.headers_mut().insert(
2782                        AUTHORIZATION,
2783                        http::HeaderValue::from_static("Bearer secret"),
2784                    );
2785                }
2786                consume(service.oneshot(call).await.unwrap()).await;
2787
2788                assert_eq!(
2789                    seen.load(std::sync::atomic::Ordering::Relaxed),
2790                    expected,
2791                    "forwarding={forward} bearer={bearer}"
2792                );
2793            }
2794        }
2795    }
2796
2797    #[tokio::test]
2798    async fn generated_responses_pin_their_content_type_and_stay_out_of_shared_caches() {
2799        let tenants = Arc::new(InMemoryTenantStore::new());
2800        tenants.insert_unchecked("key-a", Tenant::new("a", "cus_a"));
2801        let service = ServiceBuilder::new()
2802            .layer(MeterLayer::new(EdgeConfig::new(tenants)))
2803            .service(service_fn(|_request: Request<Full<Bytes>>| async {
2804                Ok::<_, Infallible>(response(&json!({
2805                    "jsonrpc":"2.0","id":1,
2806                    "result":{"resultType":"complete","tools":[],"ttlMs":60000,"cacheScope":"private"}
2807                })))
2808            }));
2809
2810        // The rejection path echoes the offending header value back to the caller.
2811        let mut malformed = request("tools/list", None, &json!({"id":1,"params":{}}), "key-a");
2812        malformed.headers_mut().insert(
2813            PROTOCOL_VERSION_HEADER,
2814            http::HeaderValue::from_static("<not-a-date>"),
2815        );
2816        let rejected = service.clone().oneshot(malformed).await.unwrap();
2817        assert_eq!(rejected.status(), StatusCode::BAD_REQUEST);
2818        assert_eq!(rejected.headers()[X_CONTENT_TYPE_OPTIONS], "nosniff");
2819        assert_eq!(rejected.headers()[CACHE_CONTROL], "no-store");
2820
2821        consume(
2822            service
2823                .clone()
2824                .oneshot(request(
2825                    "tools/list",
2826                    None,
2827                    &json!({"id":1,"params":{}}),
2828                    "key-a",
2829                ))
2830                .await
2831                .unwrap(),
2832        )
2833        .await;
2834        let hit = service
2835            .oneshot(request(
2836                "tools/list",
2837                None,
2838                &json!({"id":2,"params":{}}),
2839                "key-a",
2840            ))
2841            .await
2842            .unwrap();
2843        assert_eq!(hit.headers()["x-mcp-usage-cache"], "hit");
2844        assert_eq!(hit.headers()[X_CONTENT_TYPE_OPTIONS], "nosniff");
2845        assert_eq!(hit.headers()[CACHE_CONTROL], "private");
2846        consume(hit).await;
2847    }
2848
2849    #[tokio::test]
2850    async fn sustained_guessing_is_refused_without_affecting_valid_keys() {
2851        let tenants = Arc::new(InMemoryTenantStore::new());
2852        tenants.insert_unchecked("secret", Tenant::new("acme", "cus_acme"));
2853        let config = EdgeConfig::new(tenants)
2854            .with_auth_failure_limit(3, std::time::Duration::from_secs(60))
2855            .unwrap();
2856        let metrics = config.metrics();
2857        let service = ServiceBuilder::new()
2858            .layer(MeterLayer::new(config))
2859            .service(service_fn(|_request: Request<Full<Bytes>>| async {
2860                Ok::<_, Infallible>(response(&json!({
2861                    "jsonrpc":"2.0","id":1,"result":{"resultType":"complete"}
2862                })))
2863            }));
2864
2865        let guess = |key: &'static str| {
2866            let service = service.clone();
2867            async move {
2868                service
2869                    .oneshot(request(
2870                        "tools/list",
2871                        None,
2872                        &json!({"id":1,"params":{}}),
2873                        key,
2874                    ))
2875                    .await
2876                    .unwrap()
2877                    .status()
2878            }
2879        };
2880
2881        for _ in 0..3 {
2882            assert_eq!(guess("wrong").await, StatusCode::UNAUTHORIZED);
2883        }
2884        assert_eq!(
2885            guess("wrong").await,
2886            StatusCode::TOO_MANY_REQUESTS,
2887            "sustained guessing must stop being answered"
2888        );
2889
2890        // A valid key never consumed the budget, so it is still served.
2891        let allowed = service
2892            .clone()
2893            .oneshot(request(
2894                "tools/list",
2895                None,
2896                &json!({"id":9,"params":{}}),
2897                "secret",
2898            ))
2899            .await
2900            .unwrap();
2901        assert_eq!(
2902            allowed.status(),
2903            StatusCode::OK,
2904            "throttling guessers must not lock out legitimate callers"
2905        );
2906        consume(allowed).await;
2907
2908        let snapshot = metrics.snapshot();
2909        assert_eq!(snapshot.unauthenticated, 3);
2910        assert_eq!(snapshot.throttled, 1);
2911    }
2912
2913    #[tokio::test]
2914    async fn credential_failures_are_counted_apart_from_header_failures() {
2915        let tenants = Arc::new(InMemoryTenantStore::new());
2916        tenants.insert_unchecked("secret", Tenant::new("acme", "cus_acme"));
2917        let config = EdgeConfig::new(tenants);
2918        let metrics = config.metrics();
2919        let service = ServiceBuilder::new()
2920            .layer(MeterLayer::new(config))
2921            .service(service_fn(|_request: Request<Full<Bytes>>| async {
2922                Ok::<_, Infallible>(response(&json!({"result": {}})))
2923            }));
2924
2925        let unknown_key = service
2926            .clone()
2927            .oneshot(request(
2928                "tools/list",
2929                None,
2930                &json!({"id":1,"params":{}}),
2931                "wrong",
2932            ))
2933            .await
2934            .unwrap();
2935        assert_eq!(unknown_key.status(), StatusCode::UNAUTHORIZED);
2936
2937        // Classification runs before authentication, so this never reaches the
2938        // credential gate and must land on the other counter. A malformed version is
2939        // the case that still rejects on headers alone: a legacy one now falls back to
2940        // the body instead.
2941        let mut malformed = request("tools/list", None, &json!({"id":1,"params":{}}), "secret");
2942        malformed.headers_mut().insert(
2943            PROTOCOL_VERSION_HEADER,
2944            http::HeaderValue::from_static("not-a-date"),
2945        );
2946        assert_eq!(
2947            service.oneshot(malformed).await.unwrap().status(),
2948            StatusCode::BAD_REQUEST
2949        );
2950
2951        let snapshot = metrics.snapshot();
2952        assert_eq!(snapshot.unauthenticated, 1);
2953        assert_eq!(snapshot.rejected, 1);
2954    }
2955
2956    #[test]
2957    fn the_bearer_scheme_is_matched_case_insensitively() {
2958        // RFC 7235 makes the scheme name case-insensitive, so all of these are
2959        // the same credential and none of them is a wrong key.
2960        for header in [
2961            "Bearer secret",
2962            "bearer secret",
2963            "BEARER secret",
2964            "BeArEr secret",
2965            // RFC 7235 allows more than one space before the token.
2966            "Bearer   secret",
2967        ] {
2968            assert_eq!(bearer_token(header), Some("secret"), "{header}");
2969        }
2970
2971        // A different scheme is still not a bearer token.
2972        for header in ["Basic secret", "Bearersecret", "secret", "", " secret"] {
2973            assert_ne!(bearer_token(header), Some("secret"), "{header}");
2974        }
2975    }
2976
2977    #[tokio::test]
2978    async fn a_lowercase_bearer_credential_authenticates() {
2979        let tenants = Arc::new(InMemoryTenantStore::new());
2980        tenants.insert_unchecked("secret", Tenant::new("acme", "cus_acme"));
2981        let service = ServiceBuilder::new()
2982            .layer(MeterLayer::new(EdgeConfig::new(tenants)))
2983            .service(service_fn(|_request: Request<Full<Bytes>>| async {
2984                Ok::<_, Infallible>(response(&json!({
2985                    "jsonrpc":"2.0","id":1,"result":{"resultType":"complete"}
2986                })))
2987            }));
2988
2989        let mut call = request("tools/list", None, &json!({"id":1,"params":{}}), "secret");
2990        call.headers_mut().remove(crate::API_KEY_HEADER);
2991        call.headers_mut().insert(
2992            AUTHORIZATION,
2993            http::HeaderValue::from_static("bearer secret"),
2994        );
2995
2996        assert_eq!(
2997            service.oneshot(call).await.unwrap().status(),
2998            StatusCode::OK
2999        );
3000    }
3001
3002    #[test]
3003    fn zero_authentication_failure_window_is_rejected() {
3004        let tenants = Arc::new(InMemoryTenantStore::new());
3005        assert_eq!(
3006            EdgeConfig::new(tenants)
3007                .with_auth_failure_limit(3, Duration::ZERO)
3008                .unwrap_err(),
3009            EdgeConfigError::InvalidAuthFailureWindow
3010        );
3011    }
3012
3013    #[test]
3014    fn extracts_terminal_response_from_sse_without_confusing_notifications() {
3015        let stream = b"data: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\"}\n\n\
3016                       : keepalive\n\n\
3017                       data: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"resultType\":\"complete\"}}\n\n";
3018        let terminal = terminal_response("text/event-stream", stream).unwrap();
3019        assert_eq!(terminal["id"], 1);
3020    }
3021
3022    #[test]
3023    fn extracts_terminal_response_from_sse_with_mixed_line_endings() {
3024        let stream = b"data: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\"}\r\r\
3025                       : keepalive\r\n\r\n\
3026                       data: {\"jsonrpc\":\"2.0\",\"id\":7,\r\
3027                       data: \"result\":{\"resultType\":\"complete\"}}\n\n";
3028        let terminal = terminal_response("text/event-stream", stream).unwrap();
3029        assert_eq!(terminal["id"], 7);
3030    }
3031
3032    #[test]
3033    fn well_formed_json_that_is_not_a_response_is_not_terminal() {
3034        // Found by the property test in `properties.rs`. `application/json` used
3035        // to accept anything that parsed, so a bare scalar counted as a terminal
3036        // response while the SSE path required a JSON-RPC shape. Both now apply
3037        // the same test. Nothing was billable either way, but the two transports
3038        // classified the same body differently.
3039        for body in [
3040            b"0".as_slice(),
3041            b"null".as_slice(),
3042            b"\"a string\"".as_slice(),
3043            b"[]".as_slice(),
3044            b"{}".as_slice(),
3045            br#"{"jsonrpc":"2.0","id":1}"#.as_slice(),
3046        ] {
3047            assert_eq!(
3048                terminal_response("application/json", body),
3049                Err(UnaccountedReason::Unparseable),
3050                "{} is not a JSON-RPC response",
3051                String::from_utf8_lossy(body)
3052            );
3053        }
3054
3055        // The shapes that are responses still are.
3056        assert!(
3057            terminal_response(
3058                "application/json",
3059                br#"{"jsonrpc":"2.0","id":1,"result":{"resultType":"complete"}}"#,
3060            )
3061            .is_ok()
3062        );
3063        assert!(
3064            terminal_response(
3065                "application/json",
3066                br#"{"jsonrpc":"2.0","id":1,"error":{"code":-32020}}"#,
3067            )
3068            .is_ok()
3069        );
3070    }
3071
3072    #[tokio::test]
3073    async fn an_origin_that_declares_no_media_type_bills_nothing_and_says_so() {
3074        // The misconfiguration that costs the most: an origin behind a proxy
3075        // that strips headers, or a hand-rolled server that never sets one,
3076        // delivering real work and billing zero for all of it. Refusing to
3077        // charge is the deliberate policy; being unable to tell that it is
3078        // happening was the defect.
3079        let config = EdgeConfig::new(task_tenants());
3080        let metrics = config.metrics();
3081        let service = ServiceBuilder::new()
3082            .layer(MeterLayer::new(config))
3083            .service(service_fn(|_request: Request<Full<Bytes>>| async move {
3084                // A textbook terminal response, with no `Content-Type` at all.
3085                Ok::<_, Infallible>(
3086                    Response::builder()
3087                        .body(Full::new(Bytes::from(
3088                            json!({
3089                                "jsonrpc": "2.0",
3090                                "id": 1,
3091                                "result": {"resultType": "complete", "content": []}
3092                            })
3093                            .to_string(),
3094                        )))
3095                        .unwrap(),
3096                )
3097            }));
3098
3099        consume(
3100            service
3101                .oneshot(request(
3102                    "tools/call",
3103                    Some("long_job"),
3104                    &json!({"id": 1, "params": {"name": "long_job"}}),
3105                    "secret",
3106                ))
3107                .await
3108                .unwrap(),
3109        )
3110        .await;
3111
3112        let snapshot = metrics.snapshot();
3113        assert_eq!(snapshot.billed, 0, "an undeclared body is never charged");
3114        assert_eq!(snapshot.unrecognized_responses, 1);
3115        assert_eq!(
3116            snapshot.unrecognized_by_reason[UnaccountedReason::MediaType.index()],
3117            1,
3118            "the cause must be attributable to the media type, not to a \
3119             capture overflow or an unparseable body"
3120        );
3121        assert_eq!(
3122            snapshot.unrecognized_by_reason[UnaccountedReason::Unparseable.index()],
3123            0
3124        );
3125        assert_eq!(
3126            snapshot.unrecognized_by_reason[UnaccountedReason::Oversized.index()],
3127            0
3128        );
3129    }
3130
3131    #[test]
3132    fn content_type_matching_requires_an_exact_media_type() {
3133        assert!(has_media_type(
3134            "Application/JSON; charset=utf-8",
3135            "application/json"
3136        ));
3137        assert!(!has_media_type("application/jsonp", "application/json"));
3138
3139        // A declared media type the meter does not read is its own cause. An
3140        // operator seeing this climb has a misconfigured origin billing
3141        // nothing, which is not the same incident as a body that was read and
3142        // turned out not to be a response.
3143        assert_eq!(
3144            terminal_response("application/jsonp", b"{}"),
3145            Err(UnaccountedReason::MediaType)
3146        );
3147        assert!(
3148            terminal_response(
3149                "application/json",
3150                br#"{"jsonrpc":"2.0","id":1,"result":{"resultType":"complete"}}"#
3151            )
3152            .is_ok()
3153        );
3154    }
3155
3156    #[tokio::test]
3157    async fn oversized_request_bodies_are_rejected_before_the_origin() {
3158        let tenants = Arc::new(InMemoryTenantStore::new());
3159        tenants.insert_unchecked("secret", Tenant::new("acme", "cus_acme"));
3160        let calls = Arc::new(std::sync::atomic::AtomicU64::new(0));
3161        let inner_calls = calls.clone();
3162        let service = ServiceBuilder::new()
3163            .layer(MeterLayer::new(
3164                EdgeConfig::new(tenants).with_max_request_body(16),
3165            ))
3166            .service(service_fn(move |_request: Request<Full<Bytes>>| {
3167                inner_calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3168                async { Ok::<_, Infallible>(response(&json!({"result": {}}))) }
3169            }));
3170
3171        let response = service
3172            .oneshot(request(
3173                "tools/call",
3174                Some("tool"),
3175                &json!({"id":1,"params":{"name":"tool","payload":"too large"}}),
3176                "secret",
3177            ))
3178            .await
3179            .unwrap();
3180        assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
3181        assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 0);
3182    }
3183
3184    #[tokio::test]
3185    async fn ambiguous_or_duplicate_security_headers_are_rejected() {
3186        let tenants = Arc::new(InMemoryTenantStore::new());
3187        tenants.insert_unchecked("secret", Tenant::new("acme", "cus_acme"));
3188        let service = ServiceBuilder::new()
3189            .layer(MeterLayer::new(EdgeConfig::new(tenants)))
3190            .service(service_fn(|_request: Request<Full<Bytes>>| async {
3191                Ok::<_, Infallible>(response(&json!({"result": {}})))
3192            }));
3193
3194        let mut ambiguous = request("tools/list", None, &json!({"id":1,"params":{}}), "secret");
3195        ambiguous.headers_mut().insert(
3196            AUTHORIZATION,
3197            http::HeaderValue::from_static("Bearer secret"),
3198        );
3199        assert_eq!(
3200            service.clone().oneshot(ambiguous).await.unwrap().status(),
3201            StatusCode::UNAUTHORIZED
3202        );
3203
3204        let mut duplicate = request("tools/list", None, &json!({"id":1,"params":{}}), "secret");
3205        duplicate
3206            .headers_mut()
3207            .append(METHOD_HEADER, http::HeaderValue::from_static("tools/list"));
3208        assert_eq!(
3209            service.oneshot(duplicate).await.unwrap().status(),
3210            StatusCode::BAD_REQUEST
3211        );
3212    }
3213
3214    #[tokio::test]
3215    async fn a_header_body_mismatch_cannot_use_the_cache() {
3216        let tenants = Arc::new(InMemoryTenantStore::new());
3217        tenants.insert_unchecked("secret", Tenant::new("acme", "cus_acme"));
3218        let calls = Arc::new(std::sync::atomic::AtomicU64::new(0));
3219        let inner_calls = calls.clone();
3220        let service = ServiceBuilder::new()
3221            .layer(MeterLayer::new(EdgeConfig::new(tenants)))
3222            .service(service_fn(move |_request: Request<Full<Bytes>>| {
3223                inner_calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3224                async {
3225                    Ok::<_, Infallible>(response(&json!({
3226                        "id":1,
3227                        "result":{
3228                            "resultType":"complete",
3229                            "tools":[],
3230                            "ttlMs":60000,
3231                            "cacheScope":"private"
3232                        }
3233                    })))
3234                }
3235            }));
3236
3237        consume(
3238            service
3239                .clone()
3240                .oneshot(request(
3241                    "tools/list",
3242                    None,
3243                    &json!({"id":1,"params":{}}),
3244                    "secret",
3245                ))
3246                .await
3247                .unwrap(),
3248        )
3249        .await;
3250        consume(
3251            service
3252                .oneshot(request(
3253                    "tools/list",
3254                    None,
3255                    &json!({"id":2,"method":"resources/list","params":{}}),
3256                    "secret",
3257                ))
3258                .await
3259                .unwrap(),
3260        )
3261        .await;
3262        assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 2);
3263    }
3264}