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::{
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
36pub type BoxError = Box<dyn StdError + Send + Sync>;
38pub type MeterBody = UnsyncBoxBody<Bytes, BoxError>;
40
41const DEFAULT_CACHE_ENTRIES: usize = 512;
42const DEFAULT_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
43const DEFAULT_DEFERRED_CAPACITY: usize = 4_096;
45const DEFAULT_DEFERRED_DRAIN_PER_REQUEST: usize = 2;
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
54pub enum EdgeConfigError {
55 #[error("authentication failure window must be greater than zero")]
57 InvalidAuthFailureWindow,
58}
59
60#[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 #[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 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 #[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 #[must_use]
155 pub fn with_recorder(mut self, recorder: SharedRecorder) -> Self {
156 self.recorder = recorder;
157 self
158 }
159
160 #[must_use]
162 pub fn with_task_store(mut self, tasks: Arc<dyn TaskAttributionStore>) -> Self {
163 self.tasks = tasks;
164 self
165 }
166
167 #[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 #[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 #[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 #[must_use]
211 pub fn with_credential_forwarding(mut self, forward: bool) -> Self {
212 self.forward_credentials = forward;
213 self
214 }
215
216 #[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 #[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 #[must_use]
233 pub fn metrics(&self) -> Arc<EdgeMetrics> {
234 self.metrics.clone()
235 }
236
237 #[must_use]
248 pub fn deferred(&self) -> Arc<DeferredCompletions> {
249 self.deferred.clone()
250 }
251
252 #[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 #[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 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#[derive(Debug, Clone)]
300pub struct MeterLayer {
301 config: Arc<EdgeConfig>,
302}
303
304impl MeterLayer {
305 #[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#[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 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 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 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(
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 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 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 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 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 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 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 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 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
1067fn is_json_rpc_response(value: &Value) -> bool {
1073 value.get("result").is_some() || value.get("error").is_some()
1074}
1075
1076pub(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 let mut terminal = None;
1118 let mut data = String::new();
1119 for line in sse_lines(text) {
1120 if line.is_empty() {
1121 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
1137fn 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
1152fn 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#[cfg(test)]
1212pub(crate) mod testing {
1213 use serde_json::Value;
1214
1215 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 consume(
1928 service
1929 .clone()
1930 .oneshot(request("tools/list", None, &json!({"id":1}), "secret"))
1931 .await
1932 .unwrap(),
1933 )
1934 .await;
1935
1936 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 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 #[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 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 #[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 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 #[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 #[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 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 #[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 #[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 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 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 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 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 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 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 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 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 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 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 for header in [
2893 "Bearer secret",
2894 "bearer secret",
2895 "BEARER secret",
2896 "BeArEr secret",
2897 "Bearer secret",
2899 ] {
2900 assert_eq!(bearer_token(header), Some("secret"), "{header}");
2901 }
2902
2903 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 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 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 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 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 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}