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