1use 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::{CachedResponse, RequestMetadata, ResponseCache, cache_hints, inspect_request};
28use crate::classify::{ClassificationError, classify_protocol_headers};
29use crate::deferred::DeferredCompletions;
30use crate::metrics::EdgeMetrics;
31use crate::task::{InMemoryTaskStore, TaskAttributionStore};
32use crate::{METHOD_HEADER, NAME_HEADER, PROTOCOL_VERSION_HEADER};
33
34pub type BoxError = Box<dyn StdError + Send + Sync>;
36pub type MeterBody = UnsyncBoxBody<Bytes, BoxError>;
38
39const DEFAULT_CACHE_ENTRIES: usize = 512;
40const DEFAULT_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
41const DEFAULT_DEFERRED_CAPACITY: usize = 4_096;
43const DEFAULT_DEFERRED_DRAIN_PER_REQUEST: usize = 2;
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
52pub enum EdgeConfigError {
53 #[error("authentication failure window must be greater than zero")]
55 InvalidAuthFailureWindow,
56}
57
58#[derive(Clone)]
60pub struct EdgeConfig {
61 tenants: Arc<dyn TenantStore>,
62 recorder: SharedRecorder,
63 tasks: Arc<dyn TaskAttributionStore>,
64 cache: Arc<ResponseCache>,
65 metrics: Arc<EdgeMetrics>,
66 meter_name: String,
67 max_request_body: usize,
68 max_response_capture: usize,
69 cache_max_entries: usize,
70 cache_max_ttl: Duration,
71 share_public_cache: bool,
72 forward_credentials: bool,
73 deferred: Arc<DeferredCompletions>,
74 deferred_drain_per_request: usize,
75 auth_failure_limit: Option<Arc<AuthFailureLimit>>,
76 strict_protocol_version: bool,
77}
78
79impl std::fmt::Debug for EdgeConfig {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 f.debug_struct("EdgeConfig")
82 .field("meter_name", &self.meter_name)
83 .field("max_request_body", &self.max_request_body)
84 .field("max_response_capture", &self.max_response_capture)
85 .field("share_public_cache", &self.share_public_cache)
86 .field("forward_credentials", &self.forward_credentials)
87 .field("strict_protocol_version", &self.strict_protocol_version)
88 .field("deferred_pending", &self.deferred.len())
89 .finish_non_exhaustive()
90 }
91}
92
93impl EdgeConfig {
94 #[must_use]
96 pub fn new(tenants: Arc<dyn TenantStore>) -> Self {
97 Self {
98 tenants,
99 recorder: Arc::new(NoopRecorder),
100 tasks: Arc::new(InMemoryTaskStore::new()),
101 cache: Arc::new(ResponseCache::new(
102 DEFAULT_CACHE_ENTRIES,
103 DEFAULT_CACHE_TTL,
104 false,
105 )),
106 metrics: Arc::new(EdgeMetrics::default()),
107 meter_name: "mcp_units".to_owned(),
108 max_request_body: 1024 * 1024,
109 max_response_capture: 1024 * 1024,
110 cache_max_entries: DEFAULT_CACHE_ENTRIES,
111 cache_max_ttl: DEFAULT_CACHE_TTL,
112 share_public_cache: false,
113 forward_credentials: false,
114 deferred: Arc::new(DeferredCompletions::new(DEFAULT_DEFERRED_CAPACITY)),
115 deferred_drain_per_request: DEFAULT_DEFERRED_DRAIN_PER_REQUEST,
116 auth_failure_limit: None,
117 strict_protocol_version: false,
118 }
119 }
120
121 fn rebuild_cache(&mut self) {
128 self.cache = Arc::new(ResponseCache::new(
129 self.cache_max_entries,
130 self.cache_max_ttl,
131 self.share_public_cache,
132 ));
133 }
134
135 #[must_use]
146 pub fn with_strict_protocol_version(mut self, strict: bool) -> Self {
147 self.strict_protocol_version = strict;
148 self
149 }
150
151 #[must_use]
153 pub fn with_recorder(mut self, recorder: SharedRecorder) -> Self {
154 self.recorder = recorder;
155 self
156 }
157
158 #[must_use]
160 pub fn with_task_store(mut self, tasks: Arc<dyn TaskAttributionStore>) -> Self {
161 self.tasks = tasks;
162 self
163 }
164
165 #[must_use]
167 pub fn with_meter_name(mut self, meter_name: impl Into<String>) -> Self {
168 self.meter_name = meter_name.into();
169 self
170 }
171
172 #[must_use]
174 pub fn with_cache(mut self, max_entries: usize, max_ttl: Duration) -> Self {
175 self.cache_max_entries = max_entries;
176 self.cache_max_ttl = max_ttl;
177 self.rebuild_cache();
178 self
179 }
180
181 #[must_use]
194 pub fn with_public_cache_sharing(mut self, share: bool) -> Self {
195 self.share_public_cache = share;
196 self.rebuild_cache();
197 self
198 }
199
200 #[must_use]
209 pub fn with_credential_forwarding(mut self, forward: bool) -> Self {
210 self.forward_credentials = forward;
211 self
212 }
213
214 #[must_use]
216 pub fn with_max_request_body(mut self, bytes: usize) -> Self {
217 self.max_request_body = bytes;
218 self
219 }
220
221 #[must_use]
224 pub fn with_max_response_capture(mut self, bytes: usize) -> Self {
225 self.max_response_capture = bytes;
226 self
227 }
228
229 #[must_use]
231 pub fn metrics(&self) -> Arc<EdgeMetrics> {
232 self.metrics.clone()
233 }
234
235 #[must_use]
246 pub fn deferred(&self) -> Arc<DeferredCompletions> {
247 self.deferred.clone()
248 }
249
250 #[must_use]
256 pub fn with_deferred_drain_per_request(mut self, completions: usize) -> Self {
257 self.deferred_drain_per_request = completions;
258 self
259 }
260
261 #[must_use]
263 pub fn with_deferred_capacity(mut self, capacity: usize) -> Self {
264 self.deferred = Arc::new(DeferredCompletions::new(capacity));
265 self
266 }
267
268 pub fn with_auth_failure_limit(
284 mut self,
285 max_failures: u64,
286 window: Duration,
287 ) -> Result<Self, EdgeConfigError> {
288 if window.is_zero() {
289 return Err(EdgeConfigError::InvalidAuthFailureWindow);
290 }
291 self.auth_failure_limit = Some(Arc::new(AuthFailureLimit::new(max_failures, window)));
292 Ok(self)
293 }
294}
295
296#[derive(Debug, Clone)]
298pub struct MeterLayer {
299 config: Arc<EdgeConfig>,
300}
301
302impl MeterLayer {
303 #[must_use]
305 pub fn new(config: EdgeConfig) -> Self {
306 Self {
307 config: Arc::new(config),
308 }
309 }
310}
311
312impl<S> Layer<S> for MeterLayer {
313 type Service = MeterService<S>;
314
315 fn layer(&self, inner: S) -> Self::Service {
316 MeterService {
317 inner,
318 config: self.config.clone(),
319 }
320 }
321}
322
323#[derive(Debug, Clone)]
325pub struct MeterService<S> {
326 inner: S,
327 config: Arc<EdgeConfig>,
328}
329
330impl<S, RequestBody, ResponseBody> Service<Request<RequestBody>> for MeterService<S>
331where
332 RequestBody: Body<Data = Bytes> + Send + 'static,
333 S: Service<Request<Full<Bytes>>, Response = Response<ResponseBody>> + Clone + Send + 'static,
334 S::Future: Send + 'static,
335 S::Error: Send + 'static,
336 ResponseBody: Body<Data = Bytes> + Send + 'static,
337 ResponseBody::Error: StdError + Send + Sync + 'static,
338{
339 type Response = Response<MeterBody>;
340 type Error = S::Error;
341 type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
342
343 fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
344 self.inner.poll_ready(cx)
345 }
346
347 #[expect(
348 clippy::too_many_lines,
349 reason = "the request lifecycle stays linear so security-gate ordering is auditable"
350 )]
351 fn call(&mut self, request: Request<RequestBody>) -> Self::Future {
352 let replacement = self.inner.clone();
353 let mut inner = std::mem::replace(&mut self.inner, replacement);
354 let config = self.config.clone();
355
356 Box::pin(async move {
357 let (mut parts, request_body) = request.into_parts();
358 let classification =
364 match classify_request_headers(&parts.headers, config.strict_protocol_version) {
365 Ok(classification) => classification,
366 Err(message) => {
367 config.metrics.rejected();
368 return Ok(error_response(
369 StatusCode::BAD_REQUEST,
370 "INVALID_MCP_HEADERS",
371 &message,
372 ));
373 }
374 };
375 let api_key = match extract_api_key(&parts.headers) {
376 Ok(api_key) => api_key,
377 Err(error) => {
378 let message = match error {
379 CredentialError::Missing => "missing API key",
380 CredentialError::Invalid => "invalid or ambiguous API key headers",
381 };
382 return Ok(refuse_credential(&config, message));
383 }
384 };
385 let Some(tenant) = config.tenants.authenticate(api_key) else {
386 return Ok(refuse_credential(&config, "invalid API key"));
387 };
388
389 if config.deferred_drain_per_request > 0 {
393 config
394 .deferred
395 .drain_some(config.deferred_drain_per_request)
396 .await;
397 }
398 let authorization_context = hash_api_key(api_key);
399 config.metrics.classified();
400
401 let request_bytes =
402 match collect_request_body(request_body, config.max_request_body).await {
403 Ok(bytes) => bytes,
404 Err(RequestBodyError::TooLarge) => {
405 config.metrics.rejected();
406 return Ok(error_response(
407 StatusCode::PAYLOAD_TOO_LARGE,
408 "BODY_TOO_LARGE",
409 "MCP request body exceeds the configured limit",
410 ));
411 }
412 Err(RequestBodyError::Read) => {
413 config.metrics.rejected();
414 return Ok(error_response(
415 StatusCode::BAD_REQUEST,
416 "INVALID_BODY",
417 "failed to read MCP request body",
418 ));
419 }
420 };
421 let legacy = classification.is_from_body();
422 let protocol = match classification.resolve(&request_bytes) {
423 Ok(protocol) => protocol,
424 Err(message) => {
425 config.metrics.rejected();
426 return Ok(error_response(
427 StatusCode::BAD_REQUEST,
428 "INVALID_MCP_HEADERS",
429 &message,
430 ));
431 }
432 };
433 let metadata = inspect_request(&protocol.call, &request_bytes);
434 if let Some(key) = metadata.cache_key
435 && let Some(cached) =
436 config
437 .cache
438 .get(key, &authorization_context, metadata.request_id.as_ref())
439 {
440 config.metrics.cache_hit();
441 let mut cached_request = metadata;
442 cached_request.cache_key = None;
443 let content_type = cached
444 .headers
445 .get(CONTENT_TYPE)
446 .and_then(|value| value.to_str().ok())
447 .unwrap_or_default()
448 .to_owned();
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, completion));
462 }
463 if metadata.cache_key.is_some() {
464 config.metrics.cache_miss();
465 }
466
467 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
507enum Classification {
509 FromHeaders(ClassifiedCall),
512 FromBody,
514}
515
516impl Classification {
517 const fn is_from_body(&self) -> bool {
519 matches!(self, Self::FromBody)
520 }
521
522 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 if let Err(failure) = validate_header(version) {
546 return match failure {
547 TrustFailure::MalformedVersion(_) => {
551 Err(ClassificationError::UntrustedVersion(failure.to_string()).to_string())
552 }
553 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 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
623fn 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
638fn 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 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(cached: CachedResponse, completion: Completion) -> Response<MeterBody> {
684 let body = Bytes::from(cached.body.to_string());
685 let shares_public = completion.config.share_public_cache;
686 let observed = ObservedBody::new(Full::new(body), completion);
687 let mut response = Response::new(
688 observed
689 .map_err(|never: Infallible| -> BoxError { match never {} })
690 .boxed_unsync(),
691 );
692 *response.status_mut() = cached.status;
693 *response.version_mut() = cached.version;
694 *response.headers_mut() = cached.headers;
695 let headers = response.headers_mut();
696 headers.insert("x-mcp-usage-cache", http::HeaderValue::from_static("hit"));
697 headers.insert(
698 X_CONTENT_TYPE_OPTIONS,
699 http::HeaderValue::from_static("nosniff"),
700 );
701 if !shares_public {
702 headers.insert(CACHE_CONTROL, http::HeaderValue::from_static("private"));
705 }
706 response
707}
708
709fn cacheable_headers(headers: &HeaderMap) -> HeaderMap {
710 let mut kept = HeaderMap::new();
711 if let Some(content_type) = headers.get(CONTENT_TYPE) {
712 kept.insert(CONTENT_TYPE, content_type.clone());
713 }
714 kept
715}
716
717struct Completion {
718 config: Arc<EdgeConfig>,
719 tenant: Tenant,
720 authorization_context: String,
721 legacy: bool,
723 call: Call,
724 request: RequestMetadata,
725 response_status: StatusCode,
726 response_version: http::Version,
727 response_headers: HeaderMap,
728 content_type: String,
729}
730
731impl Completion {
732 #[expect(
733 clippy::too_many_lines,
734 reason = "terminal accounting stays linear so fail-free ordering remains auditable"
735 )]
736 async fn finish(self, bytes: Bytes) {
737 let Some(body) = terminal_response(&self.content_type, &bytes) else {
738 self.config.metrics.unrecognized_response();
739 return;
740 };
741 let response = mcp_usage_core::peek::response(&body);
742 let response = if self.legacy {
745 response.with_legacy_delivery()
746 } else {
747 response
748 };
749
750 let response_task_id = response
751 .task
752 .as_ref()
753 .and_then(|task| task.task_id.as_deref());
754 let requested_task_id = self.request.requested_task_id.as_deref();
755 if matches!(self.call.method, Method::TasksGet)
756 && let (Some(requested), Some(returned)) = (requested_task_id, response_task_id)
757 && requested != returned
758 {
759 self.config.metrics.unrecognized_response();
762 return;
763 }
764
765 if matches!(response.result_type, ResultType::Task)
766 && let Some(task_id) = response
767 .task
768 .as_ref()
769 .and_then(|task| task.task_id.as_deref())
770 && let Err(error) = self
771 .config
772 .tasks
773 .insert(
774 &self.tenant.id,
775 task_id,
776 TaskAttribution::from_call(&self.call, &self.tenant.prices),
777 )
778 .await
779 {
780 self.config.metrics.record_failure();
781 tracing::error!(error = %error, "failed to retain MCP task attribution");
782 }
783
784 let cleanup_task_id = response_task_id.or(requested_task_id);
785 let terminal_task = response.task.as_ref().is_some_and(|task| {
786 matches!(
787 task.status,
788 TaskStatus::Completed | TaskStatus::Failed | TaskStatus::Cancelled
789 )
790 });
791 let completed_task = response
792 .task
793 .as_ref()
794 .is_some_and(|task| matches!(task.status, TaskStatus::Completed));
795 let task_origin =
796 if matches!(self.call.method, Method::TasksGet) && completed_task && !response.is_error
797 {
798 if let Some(task_id) = response_task_id {
799 match self.config.tasks.claim(&self.tenant.id, task_id).await {
800 Ok(origin) => origin,
801 Err(error) => {
802 self.config.metrics.record_failure();
803 tracing::error!(error = %error, "failed to claim MCP task attribution");
804 None
805 }
806 }
807 } else {
808 None
809 }
810 } else {
811 None
812 };
813 let charge = decide_with_task_attribution(
814 &self.call,
815 &response,
816 &self.tenant.prices,
817 task_origin.as_ref(),
818 );
819 match charge {
820 Charge::Billable(billable) => {
821 let usage = UsageEvent::now(
822 &self.tenant.id,
823 &self.tenant.billing_customer_id,
824 &self.config.meter_name,
825 billable.units,
826 billable.idempotency_key,
827 );
828 match self.config.recorder.record(usage) {
829 Ok(RecordOutcome::Recorded | RecordOutcome::ZeroUnits) => {
830 self.config.metrics.billed(billable.units);
831 }
832 Ok(RecordOutcome::Duplicate) => {
833 self.config.metrics.duplicate();
834 }
835 Err(error) => {
836 self.config.metrics.record_failure();
837 tracing::error!(error = %error, "failed to buffer MCP usage");
838 if let (Some(task_id), Some(origin)) = (response_task_id, task_origin)
839 && let Err(store_error) = self
840 .config
841 .tasks
842 .insert(&self.tenant.id, task_id, origin)
843 .await
844 {
845 self.config.metrics.record_failure();
846 tracing::error!(
847 error = %store_error,
848 "failed to restore MCP task attribution after recorder rejection"
849 );
850 }
851 }
852 }
853 }
854 Charge::Free(_) => {
855 self.config.metrics.free();
856 if terminal_task
857 && !completed_task
858 && let Some(task_id) = cleanup_task_id
859 && let Err(error) = self.config.tasks.remove(&self.tenant.id, task_id).await
860 {
861 self.config.metrics.record_failure();
862 tracing::error!(error = %error, "failed to remove MCP task attribution");
863 }
864 }
865 }
866
867 if self.response_status.is_success()
868 && !self.request.is_continuation
869 && has_media_type(&self.content_type, "application/json")
870 && let Some(key) = self.request.cache_key
871 && let Some((ttl, scope)) = cache_hints(&body)
872 {
873 self.config.cache.insert(
874 key,
875 &self.authorization_context,
876 scope,
877 ttl,
878 CachedResponse {
879 status: self.response_status,
880 version: self.response_version,
881 headers: self.response_headers,
882 body,
883 },
884 );
885 }
886 }
887}
888
889struct ObservedBody<B> {
890 inner: Pin<Box<B>>,
891 completion: Option<Completion>,
892 finishing: Option<Pin<Box<dyn Future<Output = ()> + Send>>>,
893 captured: BytesMut,
894 overflowed: bool,
895 metrics: Arc<EdgeMetrics>,
896 deferred: Arc<DeferredCompletions>,
897}
898
899impl<B> ObservedBody<B> {
900 fn new(inner: B, completion: Completion) -> Self {
901 let metrics = completion.config.metrics.clone();
902 let deferred = completion.config.deferred.clone();
903 Self {
904 inner: Box::pin(inner),
905 completion: Some(completion),
906 finishing: None,
907 captured: BytesMut::new(),
908 overflowed: false,
909 metrics,
910 deferred,
911 }
912 }
913
914 fn start_completion(&mut self) -> Option<Pin<Box<dyn Future<Output = ()> + Send>>> {
919 let completion = self.completion.take()?;
920 if self.overflowed {
921 completion.config.metrics.unrecognized_response();
922 return None;
923 }
924 let captured = std::mem::take(&mut self.captured).freeze();
925 Some(Box::pin(completion.finish(captured)))
926 }
927}
928
929impl<B> Drop for ObservedBody<B> {
930 fn drop(&mut self) {
948 let mut context = Context::from_waker(std::task::Waker::noop());
949 let unfinished = if let Some(mut finishing) = self.finishing.take() {
950 finishing
951 .as_mut()
952 .poll(&mut context)
953 .is_pending()
954 .then_some(finishing)
955 } else if let Some(mut finishing) = self.start_completion() {
956 finishing
957 .as_mut()
958 .poll(&mut context)
959 .is_pending()
960 .then_some(finishing)
961 } else {
962 None
963 };
964 if let Some(finishing) = unfinished {
965 self.metrics.deferred();
966 if !self.deferred.park(finishing) {
967 self.metrics.record_failure();
969 }
970 }
971 }
972}
973
974impl<B> Body for ObservedBody<B>
975where
976 B: Body<Data = Bytes>,
977{
978 type Data = Bytes;
979 type Error = B::Error;
980
981 fn poll_frame(
982 mut self: Pin<&mut Self>,
983 cx: &mut Context<'_>,
984 ) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
985 if let Some(finishing) = self.finishing.as_mut() {
986 return match finishing.as_mut().poll(cx) {
987 Poll::Ready(()) => {
988 self.finishing = None;
989 Poll::Ready(None)
990 }
991 Poll::Pending => Poll::Pending,
992 };
993 }
994 match self.inner.as_mut().poll_frame(cx) {
995 Poll::Ready(Some(Ok(frame))) => {
996 if let Some(data) = frame.data_ref()
997 && !self.overflowed
998 {
999 let limit = self
1000 .completion
1001 .as_ref()
1002 .map_or(0, |completion| completion.config.max_response_capture);
1003 if self.captured.len().saturating_add(data.remaining()) <= limit {
1004 self.captured.extend_from_slice(data.chunk());
1005 } else {
1006 self.captured.clear();
1007 self.overflowed = true;
1008 }
1009 }
1010 Poll::Ready(Some(Ok(frame)))
1011 }
1012 Poll::Ready(None) => {
1013 if let Some(mut finishing) = self.start_completion() {
1014 return match finishing.as_mut().poll(cx) {
1015 Poll::Ready(()) => Poll::Ready(None),
1016 Poll::Pending => {
1017 self.finishing = Some(finishing);
1018 Poll::Pending
1019 }
1020 };
1021 }
1022 Poll::Ready(None)
1023 }
1024 other => other,
1025 }
1026 }
1027
1028 fn is_end_stream(&self) -> bool {
1029 self.inner.is_end_stream()
1030 }
1031
1032 fn size_hint(&self) -> http_body::SizeHint {
1033 self.inner.size_hint()
1034 }
1035}
1036
1037fn is_json_rpc_response(value: &Value) -> bool {
1043 value.get("result").is_some() || value.get("error").is_some()
1044}
1045
1046pub(crate) fn terminal_response(content_type: &str, bytes: &[u8]) -> Option<Value> {
1047 if has_media_type(content_type, "application/json") {
1048 return serde_json::from_slice(bytes)
1049 .ok()
1050 .filter(is_json_rpc_response);
1051 }
1052 if !has_media_type(content_type, "text/event-stream") {
1053 return None;
1054 }
1055 let text = std::str::from_utf8(bytes)
1056 .ok()?
1057 .replace("\r\n", "\n")
1058 .replace('\r', "\n");
1059 let mut terminal = None;
1060 for event in text.split("\n\n") {
1061 let data = event
1062 .lines()
1063 .filter_map(|line| line.strip_prefix("data:").map(str::trim_start))
1064 .collect::<Vec<_>>()
1065 .join("\n");
1066 if data.is_empty() {
1067 continue;
1068 }
1069 if let Ok(value) = serde_json::from_str::<Value>(&data)
1070 && is_json_rpc_response(&value)
1071 {
1072 terminal = Some(value);
1073 }
1074 }
1075 terminal
1076}
1077
1078fn has_media_type(content_type: &str, expected: &str) -> bool {
1079 content_type
1080 .split(';')
1081 .next()
1082 .is_some_and(|media_type| media_type.trim().eq_ignore_ascii_case(expected))
1083}
1084
1085#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1086enum RequestBodyError {
1087 TooLarge,
1088 Read,
1089}
1090
1091async fn collect_request_body<B>(body: B, limit: usize) -> Result<Bytes, RequestBodyError>
1092where
1093 B: Body<Data = Bytes>,
1094{
1095 let initial_capacity = usize::try_from(body.size_hint().lower())
1096 .unwrap_or(limit)
1097 .min(limit);
1098 let mut captured = BytesMut::with_capacity(initial_capacity);
1099 let mut body = Box::pin(body);
1100 while let Some(frame) = body.as_mut().frame().await {
1101 let frame = frame.map_err(|_| RequestBodyError::Read)?;
1102 let Ok(data) = frame.into_data() else {
1103 continue;
1104 };
1105 let Some(next_len) = captured.len().checked_add(data.len()) else {
1106 return Err(RequestBodyError::TooLarge);
1107 };
1108 if next_len > limit {
1109 return Err(RequestBodyError::TooLarge);
1110 }
1111 captured.extend_from_slice(&data);
1112 }
1113 Ok(captured.freeze())
1114}
1115
1116#[cfg(test)]
1118pub(crate) mod testing {
1119 pub(crate) use super::terminal_response;
1120}
1121
1122#[cfg(test)]
1123mod tests {
1124 use super::*;
1125 use crate::{InMemoryTenantStore, Tenant};
1126 use mcp_usage_core::PriceBook;
1127 use mcp_usage_export::{
1128 AggregatedUsage, BatchExporter, BillingPipeline, ExportFuture, RecordError, UsageRecorder,
1129 };
1130 use tower::{ServiceBuilder, ServiceExt, service_fn};
1131
1132 #[derive(Debug, Default)]
1133 struct CaptureExporter {
1134 exported: std::sync::Mutex<Vec<AggregatedUsage>>,
1135 }
1136
1137 impl CaptureExporter {
1138 fn exported(&self) -> Vec<AggregatedUsage> {
1139 self.exported
1140 .lock()
1141 .unwrap_or_else(std::sync::PoisonError::into_inner)
1142 .clone()
1143 }
1144 }
1145
1146 impl BatchExporter for CaptureExporter {
1147 fn export<'a>(&'a self, batch: &'a [AggregatedUsage]) -> ExportFuture<'a> {
1148 Box::pin(async move {
1149 self.exported
1150 .lock()
1151 .unwrap_or_else(std::sync::PoisonError::into_inner)
1152 .extend_from_slice(batch);
1153 Ok(())
1154 })
1155 }
1156 }
1157
1158 #[derive(Debug, Default)]
1159 struct RejectOnceRecorder {
1160 rejected: std::sync::atomic::AtomicBool,
1161 recorded: std::sync::Mutex<Vec<UsageEvent>>,
1162 }
1163
1164 impl UsageRecorder for RejectOnceRecorder {
1165 fn record(&self, event: UsageEvent) -> Result<RecordOutcome, RecordError> {
1166 if !self
1167 .rejected
1168 .swap(true, std::sync::atomic::Ordering::SeqCst)
1169 {
1170 return Err(RecordError::Poisoned);
1171 }
1172 self.recorded
1173 .lock()
1174 .unwrap_or_else(std::sync::PoisonError::into_inner)
1175 .push(event);
1176 Ok(RecordOutcome::Recorded)
1177 }
1178 }
1179
1180 fn request(method: &str, name: Option<&str>, body: &Value, key: &str) -> Request<Full<Bytes>> {
1181 let mut body = body.clone();
1182 if let Some(object) = body.as_object_mut() {
1183 object
1184 .entry("jsonrpc")
1185 .or_insert_with(|| Value::String("2.0".to_owned()));
1186 object
1187 .entry("method")
1188 .or_insert_with(|| Value::String(method.to_owned()));
1189 }
1190 let mut builder = Request::builder()
1191 .header(PROTOCOL_VERSION_HEADER, "2026-07-28")
1192 .header(METHOD_HEADER, method)
1193 .header(crate::API_KEY_HEADER, key);
1194 if let Some(name) = name {
1195 builder = builder.header(NAME_HEADER, name);
1196 }
1197 builder
1198 .body(Full::new(Bytes::from(body.to_string())))
1199 .unwrap()
1200 }
1201
1202 fn response(body: &Value) -> Response<Full<Bytes>> {
1203 Response::builder()
1204 .header(CONTENT_TYPE, "application/json")
1205 .body(Full::new(Bytes::from(serde_json::to_vec(&body).unwrap())))
1206 .unwrap()
1207 }
1208
1209 async fn consume(response: Response<MeterBody>) -> Bytes {
1210 response.into_body().collect().await.unwrap().to_bytes()
1211 }
1212
1213 fn release_without_end_of_stream(response: Response<MeterBody>) {
1216 let mut body = Box::pin(response.into_body());
1217 let mut context = Context::from_waker(std::task::Waker::noop());
1218 let _ = body.as_mut().poll_frame(&mut context);
1219 drop(body);
1220 }
1221
1222 struct YieldOnce(bool);
1224
1225 impl Future for YieldOnce {
1226 type Output = ();
1227 fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<()> {
1228 if self.0 {
1229 Poll::Ready(())
1230 } else {
1231 self.0 = true;
1232 context.waker().wake_by_ref();
1233 Poll::Pending
1234 }
1235 }
1236 }
1237
1238 #[derive(Debug, Default)]
1241 struct YieldingTaskStore(InMemoryTaskStore);
1242
1243 impl TaskAttributionStore for YieldingTaskStore {
1244 fn insert<'a>(
1245 &'a self,
1246 tenant_id: &'a str,
1247 task_id: &'a str,
1248 attribution: TaskAttribution,
1249 ) -> crate::TaskStoreFuture<'a, ()> {
1250 Box::pin(async move {
1251 YieldOnce(false).await;
1252 self.0.insert(tenant_id, task_id, attribution).await
1253 })
1254 }
1255 fn get<'a>(
1256 &'a self,
1257 tenant_id: &'a str,
1258 task_id: &'a str,
1259 ) -> crate::TaskStoreFuture<'a, Option<TaskAttribution>> {
1260 Box::pin(async move {
1261 YieldOnce(false).await;
1262 self.0.get(tenant_id, task_id).await
1263 })
1264 }
1265 fn claim<'a>(
1266 &'a self,
1267 tenant_id: &'a str,
1268 task_id: &'a str,
1269 ) -> crate::TaskStoreFuture<'a, Option<TaskAttribution>> {
1270 Box::pin(async move {
1271 YieldOnce(false).await;
1272 self.0.claim(tenant_id, task_id).await
1273 })
1274 }
1275 fn remove<'a>(
1276 &'a self,
1277 tenant_id: &'a str,
1278 task_id: &'a str,
1279 ) -> crate::TaskStoreFuture<'a, ()> {
1280 Box::pin(async move {
1281 YieldOnce(false).await;
1282 self.0.remove(tenant_id, task_id).await
1283 })
1284 }
1285 }
1286
1287 async fn run_task_lifecycle<S>(service: S)
1290 where
1291 S: tower::Service<Request<Full<Bytes>>, Response = Response<MeterBody>, Error = Infallible>
1292 + Clone
1293 + Send
1294 + 'static,
1295 S::Future: Send,
1296 {
1297 release_without_end_of_stream(
1298 service
1299 .clone()
1300 .oneshot(request(
1301 "tools/call",
1302 Some("long_job"),
1303 &json!({"id":1,"params":{"name":"long_job"}}),
1304 "secret",
1305 ))
1306 .await
1307 .unwrap(),
1308 );
1309 release_without_end_of_stream(
1310 service
1311 .oneshot(request(
1312 "tasks/get",
1313 None,
1314 &json!({"id":2,"params":{"taskId":"task-1"}}),
1315 "secret",
1316 ))
1317 .await
1318 .unwrap(),
1319 );
1320 }
1321
1322 fn task_lifecycle_service(
1323 config: EdgeConfig,
1324 ) -> impl tower::Service<
1325 Request<Full<Bytes>>,
1326 Response = Response<MeterBody>,
1327 Error = Infallible,
1328 Future = impl Send,
1329 > + Clone
1330 + Send
1331 + 'static {
1332 let stage = Arc::new(std::sync::atomic::AtomicU64::new(0));
1333 ServiceBuilder::new()
1334 .layer(MeterLayer::new(config))
1335 .service(service_fn(move |_request: Request<Full<Bytes>>| {
1336 let n = stage.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1337 async move {
1338 let result = if n == 0 {
1339 json!({"resultType":"task","taskId":"task-1","status":"working"})
1340 } else {
1341 json!({"resultType":"complete","taskId":"task-1","status":"completed","result":{}})
1342 };
1343 Ok::<_, Infallible>(response(&json!({"jsonrpc":"2.0","id":n,"result":result})))
1344 }
1345 }))
1346 }
1347
1348 fn task_tenants() -> Arc<InMemoryTenantStore> {
1349 let tenants = Arc::new(InMemoryTenantStore::new());
1350 tenants.insert_unchecked(
1351 "secret",
1352 Tenant::new("acme", "cus_acme")
1353 .with_prices(PriceBook::flat(1).with_name("long_job", 50)),
1354 );
1355 tenants
1356 }
1357
1358 #[tokio::test]
1359 async fn a_store_that_yields_is_parked_rather_than_abandoned() {
1360 let config = EdgeConfig::new(task_tenants())
1365 .with_task_store(Arc::new(YieldingTaskStore::default()))
1366 .with_deferred_drain_per_request(0);
1367 let metrics = config.metrics();
1368 let deferred = config.deferred();
1369
1370 run_task_lifecycle(task_lifecycle_service(config)).await;
1371
1372 assert_eq!(
1373 metrics.snapshot().billed,
1374 0,
1375 "nothing can be billed while the store has not answered"
1376 );
1377 assert!(
1378 !deferred.is_empty(),
1379 "the unfinished accounting must be parked"
1380 );
1381 assert_eq!(deferred.dropped(), 0);
1382
1383 assert!(deferred.drain().await > 0);
1385
1386 let snapshot = metrics.snapshot();
1387 assert_eq!(snapshot.billed, 1, "the completed task must be charged");
1388 assert_eq!(
1389 snapshot.billed_units, 50,
1390 "priced from the originating call"
1391 );
1392 assert_eq!(snapshot.record_failures, 0);
1393 assert!(deferred.is_empty());
1394 }
1395
1396 #[tokio::test]
1397 async fn a_later_request_drains_parked_accounting_on_its_own() {
1398 let config =
1401 EdgeConfig::new(task_tenants()).with_task_store(Arc::new(YieldingTaskStore::default()));
1402 let metrics = config.metrics();
1403 let deferred = config.deferred();
1404 let service = task_lifecycle_service(config);
1405
1406 run_task_lifecycle(service.clone()).await;
1407 assert_eq!(metrics.snapshot().billed, 0);
1408
1409 for id in 3..8 {
1411 release_without_end_of_stream(
1412 service
1413 .clone()
1414 .oneshot(request(
1415 "tools/list",
1416 None,
1417 &json!({"id":id,"params":{}}),
1418 "secret",
1419 ))
1420 .await
1421 .unwrap(),
1422 );
1423 }
1424
1425 assert_eq!(
1426 metrics.snapshot().billed,
1427 1,
1428 "subsequent requests must complete the parked accounting"
1429 );
1430 assert_eq!(metrics.snapshot().billed_units, 50);
1431 assert!(deferred.is_empty());
1432 }
1433
1434 #[tokio::test]
1435 async fn malformed_and_unauthenticated_requests_do_not_poll_deferred_work() {
1436 let tenants = Arc::new(InMemoryTenantStore::new());
1437 tenants.insert_unchecked("secret", Tenant::new("acme", "cus_acme"));
1438 let config = EdgeConfig::new(tenants);
1439 let deferred = config.deferred();
1440 let polled = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1441 assert!(deferred.park(Box::pin({
1442 let polled = Arc::clone(&polled);
1443 async move {
1444 polled.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1445 }
1446 })));
1447 let service = ServiceBuilder::new()
1448 .layer(MeterLayer::new(config))
1449 .service(service_fn(|_request: Request<Full<Bytes>>| async {
1450 Ok::<_, Infallible>(response(&json!({
1451 "jsonrpc":"2.0","id":1,"result":{"resultType":"complete"}
1452 })))
1453 }));
1454
1455 let mut malformed = request("tools/list", None, &json!({"id":1,"params":{}}), "secret");
1456 malformed.headers_mut().insert(
1457 PROTOCOL_VERSION_HEADER,
1458 http::HeaderValue::from_static("invalid"),
1459 );
1460 assert_eq!(
1461 service.clone().oneshot(malformed).await.unwrap().status(),
1462 StatusCode::BAD_REQUEST
1463 );
1464 assert_eq!(polled.load(std::sync::atomic::Ordering::Relaxed), 0);
1465
1466 assert_eq!(
1467 service
1468 .clone()
1469 .oneshot(request(
1470 "tools/list",
1471 None,
1472 &json!({"id":2,"params":{}}),
1473 "wrong",
1474 ))
1475 .await
1476 .unwrap()
1477 .status(),
1478 StatusCode::UNAUTHORIZED
1479 );
1480 assert_eq!(polled.load(std::sync::atomic::Ordering::Relaxed), 0);
1481 assert_eq!(deferred.len(), 1);
1482
1483 consume(
1484 service
1485 .oneshot(request(
1486 "tools/list",
1487 None,
1488 &json!({"id":3,"params":{}}),
1489 "secret",
1490 ))
1491 .await
1492 .unwrap(),
1493 )
1494 .await;
1495 assert_eq!(polled.load(std::sync::atomic::Ordering::Relaxed), 1);
1496 assert!(deferred.is_empty());
1497 }
1498
1499 #[tokio::test]
1500 async fn recorder_rejection_restores_claimed_task_origin_for_a_later_poll() {
1501 let tasks = Arc::new(InMemoryTaskStore::new());
1502 let recorder = Arc::new(RejectOnceRecorder::default());
1503 let metrics_config = EdgeConfig::new(task_tenants())
1504 .with_task_store(tasks.clone())
1505 .with_recorder(recorder.clone());
1506 let metrics = metrics_config.metrics();
1507 let service = task_lifecycle_service(metrics_config);
1508
1509 release_without_end_of_stream(
1510 service
1511 .clone()
1512 .oneshot(request(
1513 "tools/call",
1514 Some("long_job"),
1515 &json!({"id":1,"params":{"name":"long_job"}}),
1516 "secret",
1517 ))
1518 .await
1519 .unwrap(),
1520 );
1521 release_without_end_of_stream(
1522 service
1523 .clone()
1524 .oneshot(request(
1525 "tasks/get",
1526 None,
1527 &json!({"id":2,"params":{"taskId":"task-1"}}),
1528 "secret",
1529 ))
1530 .await
1531 .unwrap(),
1532 );
1533 assert_eq!(
1534 tasks.len(),
1535 1,
1536 "the rejected charge must restore its origin"
1537 );
1538 assert!(recorder.recorded.lock().unwrap().is_empty());
1539
1540 release_without_end_of_stream(
1541 service
1542 .oneshot(request(
1543 "tasks/get",
1544 None,
1545 &json!({"id":3,"params":{"taskId":"task-1"}}),
1546 "secret",
1547 ))
1548 .await
1549 .unwrap(),
1550 );
1551 assert!(tasks.is_empty());
1552 let captured = recorder.recorded.lock().unwrap();
1553 assert_eq!(captured.len(), 1);
1554 assert_eq!(captured[0].units, 50);
1555 let snapshot = metrics.snapshot();
1556 assert_eq!(snapshot.billed, 1);
1557 assert_eq!(snapshot.record_failures, 1);
1558 }
1559
1560 #[tokio::test]
1561 async fn missing_completed_task_id_preserves_origin_for_a_later_valid_poll() {
1562 let tasks = Arc::new(InMemoryTaskStore::new());
1563 let config = EdgeConfig::new(task_tenants()).with_task_store(tasks.clone());
1564 let metrics = config.metrics();
1565 let stage = Arc::new(std::sync::atomic::AtomicU64::new(0));
1566 let service = ServiceBuilder::new()
1567 .layer(MeterLayer::new(config))
1568 .service(service_fn(move |_request: Request<Full<Bytes>>| {
1569 let n = stage.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1570 async move {
1571 let result = match n {
1572 0 => json!({
1573 "resultType":"task",
1574 "taskId":"task-1",
1575 "status":"working"
1576 }),
1577 1 => json!({
1578 "resultType":"complete",
1579 "status":"completed",
1580 "result":{}
1581 }),
1582 _ => json!({
1583 "resultType":"complete",
1584 "taskId":"task-1",
1585 "status":"completed",
1586 "result":{}
1587 }),
1588 };
1589 Ok::<_, Infallible>(response(&json!({
1590 "jsonrpc":"2.0",
1591 "id":n,
1592 "result":result
1593 })))
1594 }
1595 }));
1596
1597 release_without_end_of_stream(
1598 service
1599 .clone()
1600 .oneshot(request(
1601 "tools/call",
1602 Some("long_job"),
1603 &json!({"id":1,"params":{"name":"long_job"}}),
1604 "secret",
1605 ))
1606 .await
1607 .unwrap(),
1608 );
1609 release_without_end_of_stream(
1610 service
1611 .clone()
1612 .oneshot(request(
1613 "tasks/get",
1614 None,
1615 &json!({"id":2,"params":{"taskId":"task-1"}}),
1616 "secret",
1617 ))
1618 .await
1619 .unwrap(),
1620 );
1621
1622 assert_eq!(
1623 tasks.len(),
1624 1,
1625 "the malformed poll must not consume the origin"
1626 );
1627 assert_eq!(metrics.snapshot().billed, 0);
1628
1629 release_without_end_of_stream(
1630 service
1631 .oneshot(request(
1632 "tasks/get",
1633 None,
1634 &json!({"id":3,"params":{"taskId":"task-1"}}),
1635 "secret",
1636 ))
1637 .await
1638 .unwrap(),
1639 );
1640
1641 assert!(tasks.is_empty());
1642 let snapshot = metrics.snapshot();
1643 assert_eq!(snapshot.billed, 1);
1644 assert_eq!(snapshot.billed_units, 50);
1645 }
1646
1647 #[tokio::test]
1648 async fn a_synchronous_store_never_parks_anything() {
1649 let config = EdgeConfig::new(task_tenants());
1652 let metrics = config.metrics();
1653 let deferred = config.deferred();
1654
1655 run_task_lifecycle(task_lifecycle_service(config)).await;
1656
1657 let snapshot = metrics.snapshot();
1658 assert_eq!(snapshot.billed, 1);
1659 assert_eq!(snapshot.billed_units, 50);
1660 assert_eq!(snapshot.deferred, 0, "nothing should have been parked");
1661 assert!(deferred.is_empty());
1662 }
1663
1664 #[tokio::test]
1668 async fn a_legacy_client_is_metered_from_its_body() {
1669 let tenants = Arc::new(InMemoryTenantStore::new());
1670 tenants.insert_unchecked(
1671 "secret",
1672 Tenant::new("acme", "cus_acme")
1673 .with_prices(PriceBook::flat(1).with_name("expensive", 25)),
1674 );
1675 let billing = Arc::new(BillingPipeline::new(CaptureExporter::default()));
1676 let config = EdgeConfig::new(tenants).with_recorder(billing.clone());
1677 let service = ServiceBuilder::new()
1678 .layer(MeterLayer::new(config))
1679 .service(service_fn(|_request: Request<Full<Bytes>>| async {
1680 Ok::<_, Infallible>(response(&json!({
1681 "jsonrpc":"2.0","id":1,
1682 "result":{"resultType":"complete","content":[]}
1683 })))
1684 }));
1685
1686 let legacy = Request::builder()
1688 .header(crate::API_KEY_HEADER, "secret")
1689 .body(Full::new(Bytes::from(
1690 json!({"jsonrpc":"2.0","id":1,"method":"tools/call",
1691 "params":{"name":"expensive"}})
1692 .to_string(),
1693 )))
1694 .unwrap();
1695
1696 let response = service.oneshot(legacy).await.unwrap();
1697 assert_eq!(response.status(), StatusCode::OK);
1698 consume(response).await;
1699 billing.flush().await.unwrap();
1700 assert_eq!(
1701 billing.exporter().exported()[0].units,
1702 25,
1703 "the body named the expensive tool, so the expensive price applies"
1704 );
1705 }
1706
1707 #[tokio::test]
1711 async fn a_legacy_response_without_a_result_type_is_still_billed() {
1712 let tenants = Arc::new(InMemoryTenantStore::new());
1713 tenants.insert_unchecked(
1714 "secret",
1715 Tenant::new("acme", "cus_acme").with_prices(PriceBook::flat(7)),
1716 );
1717 let billing = Arc::new(BillingPipeline::new(CaptureExporter::default()));
1718 let config = EdgeConfig::new(tenants).with_recorder(billing.clone());
1719 let service = ServiceBuilder::new()
1720 .layer(MeterLayer::new(config))
1721 .service(service_fn(|_request: Request<Full<Bytes>>| async {
1722 Ok::<_, Infallible>(response(&json!({
1724 "jsonrpc":"2.0","id":1,"result":{"content":[]}
1725 })))
1726 }));
1727
1728 let legacy = Request::builder()
1729 .header(crate::API_KEY_HEADER, "secret")
1730 .body(Full::new(Bytes::from(
1731 json!({"jsonrpc":"2.0","id":1,"method":"tools/call",
1732 "params":{"name":"anything"}})
1733 .to_string(),
1734 )))
1735 .unwrap();
1736
1737 let response = service.oneshot(legacy).await.unwrap();
1738 consume(response).await;
1739 billing.flush().await.unwrap();
1740 assert_eq!(
1741 billing.exporter().exported()[0].units,
1742 7,
1743 "a legacy result is delivered work and must be billed"
1744 );
1745 }
1746
1747 #[tokio::test]
1750 async fn a_modern_response_without_a_result_type_is_still_free() {
1751 let tenants = Arc::new(InMemoryTenantStore::new());
1752 tenants.insert_unchecked(
1753 "secret",
1754 Tenant::new("acme", "cus_acme").with_prices(PriceBook::flat(7)),
1755 );
1756 let billing = Arc::new(BillingPipeline::new(CaptureExporter::default()));
1757 let config = EdgeConfig::new(tenants).with_recorder(billing.clone());
1758 let service = ServiceBuilder::new()
1759 .layer(MeterLayer::new(config))
1760 .service(service_fn(|_request: Request<Full<Bytes>>| async {
1761 Ok::<_, Infallible>(response(&json!({
1762 "jsonrpc":"2.0","id":1,"result":{"content":[]}
1763 })))
1764 }));
1765
1766 let response = service
1767 .oneshot(request(
1768 "tools/call",
1769 Some("anything"),
1770 &json!({"id":1,"params":{"name":"anything"}}),
1771 "secret",
1772 ))
1773 .await
1774 .unwrap();
1775 consume(response).await;
1776 billing.flush().await.unwrap();
1777 assert!(
1778 billing.exporter().exported().is_empty(),
1779 "an unrecognized modern result must not be billed"
1780 );
1781 }
1782
1783 #[tokio::test]
1787 async fn a_legacy_client_cannot_underpay_by_lying_about_the_tool() {
1788 let tenants = Arc::new(InMemoryTenantStore::new());
1789 tenants.insert_unchecked(
1790 "secret",
1791 Tenant::new("acme", "cus_acme")
1792 .with_prices(PriceBook::flat(1).with_name("expensive", 25)),
1793 );
1794 let billing = Arc::new(BillingPipeline::new(CaptureExporter::default()));
1795 let config = EdgeConfig::new(tenants).with_recorder(billing.clone());
1796 let seen: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
1797 let observed = seen.clone();
1798 let service = ServiceBuilder::new()
1799 .layer(MeterLayer::new(config))
1800 .service(service_fn(move |request: Request<Full<Bytes>>| {
1801 let observed = observed.clone();
1802 async move {
1803 let bytes = request.into_body().collect().await.unwrap().to_bytes();
1804 let body: Value = serde_json::from_slice(&bytes).unwrap();
1805 *observed.lock().unwrap() =
1806 Some(body["params"]["name"].as_str().unwrap().into());
1807 Ok::<_, Infallible>(response(&json!({
1808 "jsonrpc":"2.0","id":1,
1809 "result":{"resultType":"complete","content":[]}
1810 })))
1811 }
1812 }));
1813
1814 let lying = Request::builder()
1818 .header(crate::API_KEY_HEADER, "secret")
1819 .header(METHOD_HEADER, "tools/list")
1820 .header(NAME_HEADER, "cheap")
1821 .body(Full::new(Bytes::from(
1822 json!({"jsonrpc":"2.0","id":1,"method":"tools/call",
1823 "params":{"name":"expensive"}})
1824 .to_string(),
1825 )))
1826 .unwrap();
1827
1828 let response = service.oneshot(lying).await.unwrap();
1829 assert_eq!(response.status(), StatusCode::OK);
1830 consume(response).await;
1831 billing.flush().await.unwrap();
1832 assert_eq!(
1833 billing.exporter().exported()[0].units,
1834 25,
1835 "billed for what the origin ran, not what the headers claimed"
1836 );
1837 assert_eq!(
1838 seen.lock().unwrap().as_deref(),
1839 Some("expensive"),
1840 "the origin ran the tool the body named"
1841 );
1842 }
1843
1844 #[tokio::test]
1846 async fn strict_mode_still_refuses_legacy_clients() {
1847 let tenants = Arc::new(InMemoryTenantStore::new());
1848 tenants.insert_unchecked("secret", Tenant::new("acme", "cus_acme"));
1849 let config = EdgeConfig::new(tenants).with_strict_protocol_version(true);
1850 let service = ServiceBuilder::new()
1851 .layer(MeterLayer::new(config))
1852 .service(service_fn(|_request: Request<Full<Bytes>>| async {
1853 Ok::<_, Infallible>(response(&json!({
1854 "jsonrpc":"2.0","id":1,"result":{"resultType":"complete"}
1855 })))
1856 }));
1857
1858 let legacy = Request::builder()
1859 .header(crate::API_KEY_HEADER, "secret")
1860 .body(Full::new(Bytes::from(
1861 json!({"jsonrpc":"2.0","id":1,"method":"tools/list"}).to_string(),
1862 )))
1863 .unwrap();
1864 assert_eq!(
1865 service.oneshot(legacy).await.unwrap().status(),
1866 StatusCode::BAD_REQUEST
1867 );
1868 }
1869
1870 #[tokio::test]
1872 async fn an_unauthenticated_legacy_request_is_refused_before_its_body_is_read() {
1873 let tenants = Arc::new(InMemoryTenantStore::new());
1874 tenants.insert_unchecked("secret", Tenant::new("acme", "cus_acme"));
1875 let config = EdgeConfig::new(tenants);
1876 let service = ServiceBuilder::new()
1877 .layer(MeterLayer::new(config))
1878 .service(service_fn(|_request: Request<Full<Bytes>>| async {
1879 Ok::<_, Infallible>(response(&json!({
1880 "jsonrpc":"2.0","id":1,"result":{"resultType":"complete"}
1881 })))
1882 }));
1883
1884 let anonymous = Request::builder()
1886 .body(Full::new(Bytes::from_static(b"{{{ not json")))
1887 .unwrap();
1888 assert_eq!(
1889 service.oneshot(anonymous).await.unwrap().status(),
1890 StatusCode::UNAUTHORIZED,
1891 "an anonymous caller must not reach the JSON parser"
1892 );
1893 }
1894
1895 #[tokio::test]
1896 async fn complete_tool_call_is_recorded_after_body_consumption() {
1897 let tenants = Arc::new(InMemoryTenantStore::new());
1898 tenants.insert_unchecked(
1899 "secret",
1900 Tenant::new("acme", "cus_acme")
1901 .with_prices(PriceBook::flat(1).with_name("expensive", 25)),
1902 );
1903 let billing = Arc::new(BillingPipeline::new(CaptureExporter::default()));
1904 let config = EdgeConfig::new(tenants).with_recorder(billing.clone());
1905 let service = ServiceBuilder::new()
1906 .layer(MeterLayer::new(config))
1907 .service(service_fn(|_request: Request<Full<Bytes>>| async {
1908 Ok::<_, Infallible>(response(&json!({
1909 "jsonrpc":"2.0","id":1,
1910 "result":{"resultType":"complete","content":[]}
1911 })))
1912 }));
1913
1914 let response = service
1915 .oneshot(request(
1916 "tools/call",
1917 Some("expensive"),
1918 &json!({"jsonrpc":"2.0","id":1,"params":{"name":"expensive"}}),
1919 "secret",
1920 ))
1921 .await
1922 .unwrap();
1923 assert_eq!(billing.pending_buckets(), 0, "not billed before delivery");
1924 consume(response).await;
1925 assert_eq!(billing.pending_buckets(), 1);
1926 billing.flush().await.unwrap();
1927 assert_eq!(billing.exporter().exported()[0].units, 25);
1928 }
1929
1930 #[tokio::test]
1931 async fn private_cache_never_crosses_authorization_contexts_and_rewrites_response_id() {
1932 let tenants = Arc::new(InMemoryTenantStore::new());
1933 tenants.insert_unchecked("key-a", Tenant::new("a", "cus_a"));
1934 tenants.insert_unchecked("key-b", Tenant::new("a", "cus_a"));
1937 let calls = Arc::new(std::sync::atomic::AtomicU64::new(0));
1938 let inner_calls = calls.clone();
1939 let config = EdgeConfig::new(tenants);
1940 let service = ServiceBuilder::new()
1941 .layer(MeterLayer::new(config))
1942 .service(service_fn(move |_request: Request<Full<Bytes>>| {
1943 inner_calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1944 async {
1945 Ok::<_, Infallible>(response(&json!({
1946 "jsonrpc":"2.0","id":1,
1947 "result":{"resultType":"complete","tools":[],"ttlMs":60000,"cacheScope":"private"}
1948 })))
1949 }
1950 }));
1951
1952 let first = service
1953 .clone()
1954 .oneshot(request(
1955 "tools/list",
1956 None,
1957 &json!({"jsonrpc":"2.0","id":1,"params":{}}),
1958 "key-a",
1959 ))
1960 .await
1961 .unwrap();
1962 consume(first).await;
1963
1964 let hit = service
1965 .clone()
1966 .oneshot(request(
1967 "tools/list",
1968 None,
1969 &json!({"jsonrpc":"2.0","id":99,"params":{}}),
1970 "key-a",
1971 ))
1972 .await
1973 .unwrap();
1974 assert_eq!(hit.headers()["x-mcp-usage-cache"], "hit");
1975 let hit_body: Value = serde_json::from_slice(&consume(hit).await).unwrap();
1976 assert_eq!(hit_body["id"], 99);
1977
1978 let other = service
1979 .oneshot(request(
1980 "tools/list",
1981 None,
1982 &json!({"jsonrpc":"2.0","id":2,"params":{}}),
1983 "key-b",
1984 ))
1985 .await
1986 .unwrap();
1987 consume(other).await;
1988 assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 2);
1989 }
1990
1991 #[tokio::test]
1992 async fn cache_hits_for_billable_resources_are_still_metered_on_delivery() {
1993 let tenants = Arc::new(InMemoryTenantStore::new());
1994 tenants.insert_unchecked(
1995 "secret",
1996 Tenant::new("acme", "cus_acme")
1997 .with_prices(PriceBook::flat(1).with_name("file:///report", 4)),
1998 );
1999 let billing = Arc::new(BillingPipeline::new(CaptureExporter::default()));
2000 let calls = Arc::new(std::sync::atomic::AtomicU64::new(0));
2001 let inner_calls = calls.clone();
2002 let service = ServiceBuilder::new()
2003 .layer(MeterLayer::new(
2004 EdgeConfig::new(tenants).with_recorder(billing.clone()),
2005 ))
2006 .service(service_fn(move |_request: Request<Full<Bytes>>| {
2007 inner_calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2008 async {
2009 Ok::<_, Infallible>(response(&json!({
2010 "jsonrpc":"2.0","id":1,
2011 "result":{
2012 "resultType":"complete",
2013 "contents":[],
2014 "ttlMs":60000,
2015 "cacheScope":"private"
2016 }
2017 })))
2018 }
2019 }));
2020
2021 for id in [1, 2] {
2022 consume(
2023 service
2024 .clone()
2025 .oneshot(request(
2026 "resources/read",
2027 Some("file:///report"),
2028 &json!({"jsonrpc":"2.0","id":id,"params":{"uri":"file:///report"}}),
2029 "secret",
2030 ))
2031 .await
2032 .unwrap(),
2033 )
2034 .await;
2035 }
2036 assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 1);
2037 billing.flush().await.unwrap();
2038 assert_eq!(billing.exporter().exported()[0].units, 8);
2039 }
2040
2041 #[tokio::test]
2042 async fn trace_context_reaches_the_rmcp_service_unchanged() {
2043 let tenants = Arc::new(InMemoryTenantStore::new());
2044 tenants.insert_unchecked("secret", Tenant::new("acme", "cus_acme"));
2045 let seen = Arc::new(std::sync::atomic::AtomicBool::new(false));
2046 let inner_seen = seen.clone();
2047 let service = ServiceBuilder::new()
2048 .layer(MeterLayer::new(EdgeConfig::new(tenants)))
2049 .service(service_fn(move |request: Request<Full<Bytes>>| {
2050 inner_seen.store(
2051 request.headers().get("traceparent").is_some_and(|value| {
2052 value == "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
2053 }),
2054 std::sync::atomic::Ordering::Relaxed,
2055 );
2056 async {
2057 Ok::<_, Infallible>(response(&json!({
2058 "jsonrpc":"2.0","id":1,"result":{"resultType":"complete"}
2059 })))
2060 }
2061 }));
2062 let mut request = request(
2063 "tools/call",
2064 Some("tool"),
2065 &json!({"id":1,"params":{"name":"tool"}}),
2066 "secret",
2067 );
2068 request.headers_mut().insert(
2069 "traceparent",
2070 http::HeaderValue::from_static(
2071 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
2072 ),
2073 );
2074 consume(service.oneshot(request).await.unwrap()).await;
2075 assert!(seen.load(std::sync::atomic::Ordering::Relaxed));
2076 }
2077
2078 #[tokio::test]
2079 async fn task_completion_uses_origin_price_and_repeat_poll_is_free() {
2080 let tenants = Arc::new(InMemoryTenantStore::new());
2081 tenants.insert_unchecked(
2082 "secret",
2083 Tenant::new("acme", "cus_acme")
2084 .with_prices(PriceBook::flat(1).with_name("long_job", 50)),
2085 );
2086 let billing = Arc::new(BillingPipeline::new(CaptureExporter::default()));
2087 let stage = Arc::new(std::sync::atomic::AtomicU64::new(0));
2088 let inner_stage = stage.clone();
2089 let service = ServiceBuilder::new()
2090 .layer(MeterLayer::new(
2091 EdgeConfig::new(tenants).with_recorder(billing.clone()),
2092 ))
2093 .service(service_fn(move |_request: Request<Full<Bytes>>| {
2094 let n = inner_stage.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2095 async move {
2096 let result = if n == 0 {
2097 json!({"resultType":"task","taskId":"task-1","status":"working"})
2098 } else {
2099 json!({"resultType":"complete","taskId":"task-1","status":"completed","result":{}})
2100 };
2101 Ok::<_, Infallible>(response(&json!({"jsonrpc":"2.0","id":n,"result":result})))
2102 }
2103 }));
2104
2105 consume(
2106 service
2107 .clone()
2108 .oneshot(request(
2109 "tools/call",
2110 Some("long_job"),
2111 &json!({"id":1,"params":{"name":"long_job"}}),
2112 "secret",
2113 ))
2114 .await
2115 .unwrap(),
2116 )
2117 .await;
2118 for id in [2, 3] {
2119 consume(
2120 service
2121 .clone()
2122 .oneshot(request(
2123 "tasks/get",
2124 None,
2125 &json!({"id":id,"params":{"taskId":"task-1"}}),
2126 "secret",
2127 ))
2128 .await
2129 .unwrap(),
2130 )
2131 .await;
2132 }
2133 billing.flush().await.unwrap();
2134 let exported = billing.exporter().exported();
2135 assert_eq!(exported.len(), 1);
2136 assert_eq!(exported[0].units, 50);
2137 }
2138
2139 #[tokio::test]
2140 async fn mismatched_task_response_cannot_bill_another_tasks_origin() {
2141 let tenants = Arc::new(InMemoryTenantStore::new());
2142 tenants.insert_unchecked(
2143 "secret",
2144 Tenant::new("acme", "cus_acme")
2145 .with_prices(PriceBook::flat(1).with_name("expensive", 100)),
2146 );
2147 let billing = Arc::new(BillingPipeline::new(CaptureExporter::default()));
2148 let stage = Arc::new(std::sync::atomic::AtomicU64::new(0));
2149 let inner_stage = stage.clone();
2150 let service = ServiceBuilder::new()
2151 .layer(MeterLayer::new(
2152 EdgeConfig::new(tenants).with_recorder(billing.clone()),
2153 ))
2154 .service(service_fn(move |_request: Request<Full<Bytes>>| {
2155 let n = inner_stage.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2156 async move {
2157 let result = match n {
2158 0 => json!({"resultType":"task","taskId":"task-1","status":"working"}),
2159 1 => json!({"resultType":"task","taskId":"task-2","status":"working"}),
2160 _ => json!({
2161 "resultType":"complete",
2162 "taskId":"task-2",
2163 "status":"completed",
2164 "result":{}
2165 }),
2166 };
2167 Ok::<_, Infallible>(response(&json!({"jsonrpc":"2.0","id":n,"result":result})))
2168 }
2169 }));
2170
2171 for (name, id) in [("cheap", 1), ("expensive", 2)] {
2172 consume(
2173 service
2174 .clone()
2175 .oneshot(request(
2176 "tools/call",
2177 Some(name),
2178 &json!({"id":id,"params":{"name":name}}),
2179 "secret",
2180 ))
2181 .await
2182 .unwrap(),
2183 )
2184 .await;
2185 }
2186 consume(
2187 service
2188 .oneshot(request(
2189 "tasks/get",
2190 None,
2191 &json!({"id":3,"params":{"taskId":"task-1"}}),
2192 "secret",
2193 ))
2194 .await
2195 .unwrap(),
2196 )
2197 .await;
2198 assert_eq!(billing.flush().await.unwrap(), 0);
2199 }
2200
2201 #[tokio::test]
2202 async fn accounting_runs_when_the_transport_stops_polling_early() {
2203 let tenants = Arc::new(InMemoryTenantStore::new());
2213 tenants.insert_unchecked(
2214 "secret",
2215 Tenant::new("acme", "cus_acme").with_prices(PriceBook::flat(1).with_name("tool", 7)),
2216 );
2217 let billing = Arc::new(BillingPipeline::new(CaptureExporter::default()));
2218 let config = EdgeConfig::new(tenants).with_recorder(billing.clone());
2219 let metrics = config.metrics();
2220 let service = ServiceBuilder::new()
2221 .layer(MeterLayer::new(config))
2222 .service(service_fn(|_request: Request<Full<Bytes>>| async {
2223 Ok::<_, Infallible>(response(&json!({
2224 "jsonrpc":"2.0","id":1,
2225 "result":{"resultType":"complete","content":[]}
2226 })))
2227 }));
2228
2229 let response = service
2230 .oneshot(request(
2231 "tools/call",
2232 Some("tool"),
2233 &json!({"id":1,"params":{"name":"tool"}}),
2234 "secret",
2235 ))
2236 .await
2237 .unwrap();
2238
2239 let mut body = Box::pin(response.into_body());
2240 let mut context = Context::from_waker(std::task::Waker::noop());
2241 assert!(
2244 matches!(
2245 body.as_mut().poll_frame(&mut context),
2246 Poll::Ready(Some(Ok(_)))
2247 ),
2248 "expected one data frame"
2249 );
2250 assert_eq!(
2251 metrics.snapshot().billed,
2252 0,
2253 "nothing should be billed before the body is released"
2254 );
2255
2256 drop(body);
2257
2258 let snapshot = metrics.snapshot();
2259 assert_eq!(
2260 snapshot.billed, 1,
2261 "the delivery must still be accounted for"
2262 );
2263 assert_eq!(snapshot.billed_units, 7);
2264 assert_eq!(snapshot.record_failures, 0);
2265 assert_eq!(billing.pending_buckets(), 1);
2266 }
2267
2268 #[tokio::test]
2269 async fn a_fully_consumed_body_is_accounted_for_exactly_once() {
2270 let tenants = Arc::new(InMemoryTenantStore::new());
2273 tenants.insert_unchecked(
2274 "secret",
2275 Tenant::new("acme", "cus_acme").with_prices(PriceBook::flat(1).with_name("tool", 5)),
2276 );
2277 let config = EdgeConfig::new(tenants);
2278 let metrics = config.metrics();
2279 let service = ServiceBuilder::new()
2280 .layer(MeterLayer::new(config))
2281 .service(service_fn(|_request: Request<Full<Bytes>>| async {
2282 Ok::<_, Infallible>(response(&json!({
2283 "jsonrpc":"2.0","id":1,
2284 "result":{"resultType":"complete","content":[]}
2285 })))
2286 }));
2287
2288 consume(
2289 service
2290 .oneshot(request(
2291 "tools/call",
2292 Some("tool"),
2293 &json!({"id":1,"params":{"name":"tool"}}),
2294 "secret",
2295 ))
2296 .await
2297 .unwrap(),
2298 )
2299 .await;
2300
2301 let snapshot = metrics.snapshot();
2302 assert_eq!(snapshot.billed, 1);
2303 assert_eq!(snapshot.billed_units, 5);
2304 }
2305
2306 #[tokio::test]
2307 async fn public_results_cross_tenants_only_when_sharing_is_enabled() {
2308 for (share, expected_origin_calls) in [(false, 2), (true, 1)] {
2313 let tenants = Arc::new(InMemoryTenantStore::new());
2314 tenants.insert_unchecked("key-a", Tenant::new("a", "cus_a"));
2315 tenants.insert_unchecked("key-b", Tenant::new("b", "cus_b"));
2316 let calls = Arc::new(std::sync::atomic::AtomicU64::new(0));
2317 let inner_calls = calls.clone();
2318 let service = ServiceBuilder::new()
2319 .layer(MeterLayer::new(
2320 EdgeConfig::new(tenants).with_public_cache_sharing(share),
2321 ))
2322 .service(service_fn(move |_request: Request<Full<Bytes>>| {
2323 inner_calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2324 async {
2325 Ok::<_, Infallible>(response(&json!({
2326 "jsonrpc":"2.0","id":1,
2327 "result":{
2328 "resultType":"complete","tools":[],
2329 "ttlMs":60000,"cacheScope":"public"
2330 }
2331 })))
2332 }
2333 }));
2334
2335 for key in ["key-a", "key-b"] {
2336 consume(
2337 service
2338 .clone()
2339 .oneshot(request(
2340 "tools/list",
2341 None,
2342 &json!({"jsonrpc":"2.0","id":1,"params":{}}),
2343 key,
2344 ))
2345 .await
2346 .unwrap(),
2347 )
2348 .await;
2349 }
2350
2351 assert_eq!(
2352 calls.load(std::sync::atomic::Ordering::Relaxed),
2353 expected_origin_calls,
2354 "with_public_cache_sharing({share})"
2355 );
2356 }
2357 }
2358
2359 #[tokio::test]
2360 async fn the_consumed_credential_does_not_reach_the_inner_service() {
2361 let tenants = Arc::new(InMemoryTenantStore::new());
2362 tenants.insert_unchecked("secret", Tenant::new("acme", "cus_acme"));
2363
2364 for (forward, expected) in [(false, false), (true, true)] {
2365 for bearer in [false, true] {
2366 let seen = Arc::new(std::sync::atomic::AtomicBool::new(false));
2367 let inner_seen = seen.clone();
2368 let service = ServiceBuilder::new()
2369 .layer(MeterLayer::new(
2370 EdgeConfig::new(tenants.clone()).with_credential_forwarding(forward),
2371 ))
2372 .service(service_fn(move |request: Request<Full<Bytes>>| {
2373 let headers = request.headers();
2374 inner_seen.store(
2375 headers.contains_key(crate::API_KEY_HEADER)
2376 || headers.contains_key(AUTHORIZATION),
2377 std::sync::atomic::Ordering::Relaxed,
2378 );
2379 async {
2380 Ok::<_, Infallible>(response(&json!({
2381 "jsonrpc":"2.0","id":1,"result":{"resultType":"complete"}
2382 })))
2383 }
2384 }));
2385
2386 let mut call = request(
2387 "tools/call",
2388 Some("tool"),
2389 &json!({"id":1,"params":{"name":"tool"}}),
2390 "secret",
2391 );
2392 if bearer {
2393 call.headers_mut().remove(crate::API_KEY_HEADER);
2395 call.headers_mut().insert(
2396 AUTHORIZATION,
2397 http::HeaderValue::from_static("Bearer secret"),
2398 );
2399 }
2400 consume(service.oneshot(call).await.unwrap()).await;
2401
2402 assert_eq!(
2403 seen.load(std::sync::atomic::Ordering::Relaxed),
2404 expected,
2405 "forwarding={forward} bearer={bearer}"
2406 );
2407 }
2408 }
2409 }
2410
2411 #[tokio::test]
2412 async fn generated_responses_pin_their_content_type_and_stay_out_of_shared_caches() {
2413 let tenants = Arc::new(InMemoryTenantStore::new());
2414 tenants.insert_unchecked("key-a", Tenant::new("a", "cus_a"));
2415 let service = ServiceBuilder::new()
2416 .layer(MeterLayer::new(EdgeConfig::new(tenants)))
2417 .service(service_fn(|_request: Request<Full<Bytes>>| async {
2418 Ok::<_, Infallible>(response(&json!({
2419 "jsonrpc":"2.0","id":1,
2420 "result":{"resultType":"complete","tools":[],"ttlMs":60000,"cacheScope":"private"}
2421 })))
2422 }));
2423
2424 let mut malformed = request("tools/list", None, &json!({"id":1,"params":{}}), "key-a");
2426 malformed.headers_mut().insert(
2427 PROTOCOL_VERSION_HEADER,
2428 http::HeaderValue::from_static("<not-a-date>"),
2429 );
2430 let rejected = service.clone().oneshot(malformed).await.unwrap();
2431 assert_eq!(rejected.status(), StatusCode::BAD_REQUEST);
2432 assert_eq!(rejected.headers()[X_CONTENT_TYPE_OPTIONS], "nosniff");
2433 assert_eq!(rejected.headers()[CACHE_CONTROL], "no-store");
2434
2435 consume(
2436 service
2437 .clone()
2438 .oneshot(request(
2439 "tools/list",
2440 None,
2441 &json!({"id":1,"params":{}}),
2442 "key-a",
2443 ))
2444 .await
2445 .unwrap(),
2446 )
2447 .await;
2448 let hit = service
2449 .oneshot(request(
2450 "tools/list",
2451 None,
2452 &json!({"id":2,"params":{}}),
2453 "key-a",
2454 ))
2455 .await
2456 .unwrap();
2457 assert_eq!(hit.headers()["x-mcp-usage-cache"], "hit");
2458 assert_eq!(hit.headers()[X_CONTENT_TYPE_OPTIONS], "nosniff");
2459 assert_eq!(hit.headers()[CACHE_CONTROL], "private");
2460 consume(hit).await;
2461 }
2462
2463 #[tokio::test]
2464 async fn sustained_guessing_is_refused_without_affecting_valid_keys() {
2465 let tenants = Arc::new(InMemoryTenantStore::new());
2466 tenants.insert_unchecked("secret", Tenant::new("acme", "cus_acme"));
2467 let config = EdgeConfig::new(tenants)
2468 .with_auth_failure_limit(3, std::time::Duration::from_secs(60))
2469 .unwrap();
2470 let metrics = config.metrics();
2471 let service = ServiceBuilder::new()
2472 .layer(MeterLayer::new(config))
2473 .service(service_fn(|_request: Request<Full<Bytes>>| async {
2474 Ok::<_, Infallible>(response(&json!({
2475 "jsonrpc":"2.0","id":1,"result":{"resultType":"complete"}
2476 })))
2477 }));
2478
2479 let guess = |key: &'static str| {
2480 let service = service.clone();
2481 async move {
2482 service
2483 .oneshot(request(
2484 "tools/list",
2485 None,
2486 &json!({"id":1,"params":{}}),
2487 key,
2488 ))
2489 .await
2490 .unwrap()
2491 .status()
2492 }
2493 };
2494
2495 for _ in 0..3 {
2496 assert_eq!(guess("wrong").await, StatusCode::UNAUTHORIZED);
2497 }
2498 assert_eq!(
2499 guess("wrong").await,
2500 StatusCode::TOO_MANY_REQUESTS,
2501 "sustained guessing must stop being answered"
2502 );
2503
2504 let allowed = service
2506 .clone()
2507 .oneshot(request(
2508 "tools/list",
2509 None,
2510 &json!({"id":9,"params":{}}),
2511 "secret",
2512 ))
2513 .await
2514 .unwrap();
2515 assert_eq!(
2516 allowed.status(),
2517 StatusCode::OK,
2518 "throttling guessers must not lock out legitimate callers"
2519 );
2520 consume(allowed).await;
2521
2522 let snapshot = metrics.snapshot();
2523 assert_eq!(snapshot.unauthenticated, 3);
2524 assert_eq!(snapshot.throttled, 1);
2525 }
2526
2527 #[tokio::test]
2528 async fn credential_failures_are_counted_apart_from_header_failures() {
2529 let tenants = Arc::new(InMemoryTenantStore::new());
2530 tenants.insert_unchecked("secret", Tenant::new("acme", "cus_acme"));
2531 let config = EdgeConfig::new(tenants);
2532 let metrics = config.metrics();
2533 let service = ServiceBuilder::new()
2534 .layer(MeterLayer::new(config))
2535 .service(service_fn(|_request: Request<Full<Bytes>>| async {
2536 Ok::<_, Infallible>(response(&json!({"result": {}})))
2537 }));
2538
2539 let unknown_key = service
2540 .clone()
2541 .oneshot(request(
2542 "tools/list",
2543 None,
2544 &json!({"id":1,"params":{}}),
2545 "wrong",
2546 ))
2547 .await
2548 .unwrap();
2549 assert_eq!(unknown_key.status(), StatusCode::UNAUTHORIZED);
2550
2551 let mut malformed = request("tools/list", None, &json!({"id":1,"params":{}}), "secret");
2556 malformed.headers_mut().insert(
2557 PROTOCOL_VERSION_HEADER,
2558 http::HeaderValue::from_static("not-a-date"),
2559 );
2560 assert_eq!(
2561 service.oneshot(malformed).await.unwrap().status(),
2562 StatusCode::BAD_REQUEST
2563 );
2564
2565 let snapshot = metrics.snapshot();
2566 assert_eq!(snapshot.unauthenticated, 1);
2567 assert_eq!(snapshot.rejected, 1);
2568 }
2569
2570 #[test]
2571 fn the_bearer_scheme_is_matched_case_insensitively() {
2572 for header in [
2575 "Bearer secret",
2576 "bearer secret",
2577 "BEARER secret",
2578 "BeArEr secret",
2579 "Bearer secret",
2581 ] {
2582 assert_eq!(bearer_token(header), Some("secret"), "{header}");
2583 }
2584
2585 for header in ["Basic secret", "Bearersecret", "secret", "", " secret"] {
2587 assert_ne!(bearer_token(header), Some("secret"), "{header}");
2588 }
2589 }
2590
2591 #[tokio::test]
2592 async fn a_lowercase_bearer_credential_authenticates() {
2593 let tenants = Arc::new(InMemoryTenantStore::new());
2594 tenants.insert_unchecked("secret", Tenant::new("acme", "cus_acme"));
2595 let service = ServiceBuilder::new()
2596 .layer(MeterLayer::new(EdgeConfig::new(tenants)))
2597 .service(service_fn(|_request: Request<Full<Bytes>>| async {
2598 Ok::<_, Infallible>(response(&json!({
2599 "jsonrpc":"2.0","id":1,"result":{"resultType":"complete"}
2600 })))
2601 }));
2602
2603 let mut call = request("tools/list", None, &json!({"id":1,"params":{}}), "secret");
2604 call.headers_mut().remove(crate::API_KEY_HEADER);
2605 call.headers_mut().insert(
2606 AUTHORIZATION,
2607 http::HeaderValue::from_static("bearer secret"),
2608 );
2609
2610 assert_eq!(
2611 service.oneshot(call).await.unwrap().status(),
2612 StatusCode::OK
2613 );
2614 }
2615
2616 #[test]
2617 fn zero_authentication_failure_window_is_rejected() {
2618 let tenants = Arc::new(InMemoryTenantStore::new());
2619 assert_eq!(
2620 EdgeConfig::new(tenants)
2621 .with_auth_failure_limit(3, Duration::ZERO)
2622 .unwrap_err(),
2623 EdgeConfigError::InvalidAuthFailureWindow
2624 );
2625 }
2626
2627 #[test]
2628 fn extracts_terminal_response_from_sse_without_confusing_notifications() {
2629 let stream = b"data: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\"}\n\n\
2630 : keepalive\n\n\
2631 data: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"resultType\":\"complete\"}}\n\n";
2632 let terminal = terminal_response("text/event-stream", stream).unwrap();
2633 assert_eq!(terminal["id"], 1);
2634 }
2635
2636 #[test]
2637 fn extracts_terminal_response_from_sse_with_mixed_line_endings() {
2638 let stream = b"data: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\"}\r\r\
2639 : keepalive\r\n\r\n\
2640 data: {\"jsonrpc\":\"2.0\",\"id\":7,\r\
2641 data: \"result\":{\"resultType\":\"complete\"}}\n\n";
2642 let terminal = terminal_response("text/event-stream", stream).unwrap();
2643 assert_eq!(terminal["id"], 7);
2644 }
2645
2646 #[test]
2647 fn well_formed_json_that_is_not_a_response_is_not_terminal() {
2648 for body in [
2654 b"0".as_slice(),
2655 b"null".as_slice(),
2656 b"\"a string\"".as_slice(),
2657 b"[]".as_slice(),
2658 b"{}".as_slice(),
2659 br#"{"jsonrpc":"2.0","id":1}"#.as_slice(),
2660 ] {
2661 assert!(
2662 terminal_response("application/json", body).is_none(),
2663 "{} is not a JSON-RPC response",
2664 String::from_utf8_lossy(body)
2665 );
2666 }
2667
2668 assert!(
2670 terminal_response(
2671 "application/json",
2672 br#"{"jsonrpc":"2.0","id":1,"result":{"resultType":"complete"}}"#,
2673 )
2674 .is_some()
2675 );
2676 assert!(
2677 terminal_response(
2678 "application/json",
2679 br#"{"jsonrpc":"2.0","id":1,"error":{"code":-32020}}"#,
2680 )
2681 .is_some()
2682 );
2683 }
2684
2685 #[test]
2686 fn content_type_matching_requires_an_exact_media_type() {
2687 assert!(has_media_type(
2688 "Application/JSON; charset=utf-8",
2689 "application/json"
2690 ));
2691 assert!(!has_media_type("application/jsonp", "application/json"));
2692 assert!(terminal_response("application/jsonp", b"{}").is_none());
2693 }
2694
2695 #[tokio::test]
2696 async fn oversized_request_bodies_are_rejected_before_the_origin() {
2697 let tenants = Arc::new(InMemoryTenantStore::new());
2698 tenants.insert_unchecked("secret", Tenant::new("acme", "cus_acme"));
2699 let calls = Arc::new(std::sync::atomic::AtomicU64::new(0));
2700 let inner_calls = calls.clone();
2701 let service = ServiceBuilder::new()
2702 .layer(MeterLayer::new(
2703 EdgeConfig::new(tenants).with_max_request_body(16),
2704 ))
2705 .service(service_fn(move |_request: Request<Full<Bytes>>| {
2706 inner_calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2707 async { Ok::<_, Infallible>(response(&json!({"result": {}}))) }
2708 }));
2709
2710 let response = service
2711 .oneshot(request(
2712 "tools/call",
2713 Some("tool"),
2714 &json!({"id":1,"params":{"name":"tool","payload":"too large"}}),
2715 "secret",
2716 ))
2717 .await
2718 .unwrap();
2719 assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
2720 assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 0);
2721 }
2722
2723 #[tokio::test]
2724 async fn ambiguous_or_duplicate_security_headers_are_rejected() {
2725 let tenants = Arc::new(InMemoryTenantStore::new());
2726 tenants.insert_unchecked("secret", Tenant::new("acme", "cus_acme"));
2727 let service = ServiceBuilder::new()
2728 .layer(MeterLayer::new(EdgeConfig::new(tenants)))
2729 .service(service_fn(|_request: Request<Full<Bytes>>| async {
2730 Ok::<_, Infallible>(response(&json!({"result": {}})))
2731 }));
2732
2733 let mut ambiguous = request("tools/list", None, &json!({"id":1,"params":{}}), "secret");
2734 ambiguous.headers_mut().insert(
2735 AUTHORIZATION,
2736 http::HeaderValue::from_static("Bearer secret"),
2737 );
2738 assert_eq!(
2739 service.clone().oneshot(ambiguous).await.unwrap().status(),
2740 StatusCode::UNAUTHORIZED
2741 );
2742
2743 let mut duplicate = request("tools/list", None, &json!({"id":1,"params":{}}), "secret");
2744 duplicate
2745 .headers_mut()
2746 .append(METHOD_HEADER, http::HeaderValue::from_static("tools/list"));
2747 assert_eq!(
2748 service.oneshot(duplicate).await.unwrap().status(),
2749 StatusCode::BAD_REQUEST
2750 );
2751 }
2752
2753 #[tokio::test]
2754 async fn a_header_body_mismatch_cannot_use_the_cache() {
2755 let tenants = Arc::new(InMemoryTenantStore::new());
2756 tenants.insert_unchecked("secret", Tenant::new("acme", "cus_acme"));
2757 let calls = Arc::new(std::sync::atomic::AtomicU64::new(0));
2758 let inner_calls = calls.clone();
2759 let service = ServiceBuilder::new()
2760 .layer(MeterLayer::new(EdgeConfig::new(tenants)))
2761 .service(service_fn(move |_request: Request<Full<Bytes>>| {
2762 inner_calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2763 async {
2764 Ok::<_, Infallible>(response(&json!({
2765 "id":1,
2766 "result":{
2767 "resultType":"complete",
2768 "tools":[],
2769 "ttlMs":60000,
2770 "cacheScope":"private"
2771 }
2772 })))
2773 }
2774 }));
2775
2776 consume(
2777 service
2778 .clone()
2779 .oneshot(request(
2780 "tools/list",
2781 None,
2782 &json!({"id":1,"params":{}}),
2783 "secret",
2784 ))
2785 .await
2786 .unwrap(),
2787 )
2788 .await;
2789 consume(
2790 service
2791 .oneshot(request(
2792 "tools/list",
2793 None,
2794 &json!({"id":2,"method":"resources/list","params":{}}),
2795 "secret",
2796 ))
2797 .await
2798 .unwrap(),
2799 )
2800 .await;
2801 assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 2);
2802 }
2803}