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(&parts.method, &request_bytes) {
425 Ok(protocol) => protocol,
426 Err(message) => {
427 config.metrics.rejected();
428 return Ok(error_response(
429 StatusCode::BAD_REQUEST,
430 "INVALID_MCP_HEADERS",
431 &message,
432 ));
433 }
434 };
435 let Some(protocol) = protocol else {
436 if !config.forward_credentials {
437 parts.headers.remove(crate::API_KEY_HEADER);
438 parts.headers.remove(AUTHORIZATION);
439 }
440 let response = inner
441 .call(Request::from_parts(parts, Full::new(request_bytes)))
442 .await?;
443 return Ok(response.map(|body| {
444 body.map_err(|error| -> BoxError { Box::new(error) })
445 .boxed_unsync()
446 }));
447 };
448 let metadata = inspect_request(&protocol.call, &request_bytes);
449 if let Some(key) = metadata.cache_key
450 && let Some(cached) = config.cache.get(key, &authorization_context)
451 {
452 config.metrics.cache_hit();
453 let mut cached_request = metadata;
454 cached_request.cache_key = None;
455 let content_type = cached
456 .headers
457 .get(CONTENT_TYPE)
458 .and_then(|value| value.to_str().ok())
459 .unwrap_or_default()
460 .to_owned();
461 let body = render_with_id(&cached.body, cached_request.request_id.as_ref());
462 let completion = Completion {
463 config,
464 tenant,
465 authorization_context,
466 legacy,
467 call: protocol.call,
468 request: cached_request,
469 response_status: cached.status,
470 response_version: cached.version,
471 response_headers: cached.headers.clone(),
472 content_type,
473 };
474 return Ok(cached_response(&cached, body, completion));
475 }
476 if metadata.cache_key.is_some() {
477 config.metrics.cache_miss();
478 }
479
480 if !config.forward_credentials {
484 parts.headers.remove(crate::API_KEY_HEADER);
485 parts.headers.remove(AUTHORIZATION);
486 }
487 let request = Request::from_parts(parts, Full::new(request_bytes));
488 let response = inner.call(request).await?;
489 let (parts, body) = response.into_parts();
490 let completion = Completion {
491 config,
492 tenant,
493 authorization_context,
494 legacy,
495 call: protocol.call,
496 request: metadata,
497 response_status: parts.status,
498 response_version: parts.version,
499 response_headers: cacheable_headers(&parts.headers),
500 content_type: parts
501 .headers
502 .get(CONTENT_TYPE)
503 .and_then(|value| value.to_str().ok())
504 .unwrap_or_default()
505 .to_owned(),
506 };
507 let observed = ObservedBody::new(body, completion);
508 let boxed = observed
509 .map_err(|error| -> BoxError { Box::new(error) })
510 .boxed_unsync();
511 Ok(Response::from_parts(parts, boxed))
512 })
513 }
514}
515
516#[derive(Debug, Clone)]
518pub struct ClassifiedCall {
519 pub call: Call,
521}
522
523#[derive(Debug, Clone)]
525pub enum Classification {
526 FromHeaders(ClassifiedCall),
529 FromBody,
531}
532
533impl Classification {
534 #[must_use]
536 pub const fn is_from_body(&self) -> bool {
537 matches!(self, Self::FromBody)
538 }
539
540 pub fn resolve(
547 self,
548 http_method: &http::Method,
549 body: &[u8],
550 ) -> Result<Option<ClassifiedCall>, String> {
551 match self {
552 Self::FromHeaders(classified) => Ok(Some(classified)),
553 Self::FromBody => {
554 if matches!(*http_method, http::Method::GET | http::Method::DELETE) {
555 return if body.is_empty() {
556 Ok(None)
557 } else {
558 Err("transport requests must have an empty body".into())
559 };
560 }
561 if let Ok(value) = serde_json::from_slice::<Value>(body)
564 && value.get("method").is_none()
565 && value.get("jsonrpc").and_then(Value::as_str) == Some("2.0")
566 && value
567 .get("id")
568 .is_some_and(|id| id.is_string() || id.is_number())
569 && (value.get("result").is_some() ^ value.get("error").is_some())
570 {
571 return Ok(None);
572 }
573 let (method, name) =
574 mcp_usage_core::classify_body(body).map_err(|error| error.to_string())?;
575 if method.as_str().starts_with("notifications/") {
576 return Ok(None);
577 }
578 Ok(Some(ClassifiedCall {
579 call: Call::new(method, name),
580 }))
581 }
582 }
583 }
584}
585
586pub fn classify_request_headers(
591 headers: &HeaderMap,
592 strict: bool,
593) -> Result<Classification, String> {
594 let version = optional_header_str(headers, PROTOCOL_VERSION_HEADER)?;
595 let method = optional_header_str(headers, METHOD_HEADER)?;
596 let name = optional_header_str(headers, NAME_HEADER)?;
597
598 if let Err(failure) = validate_header(version) {
601 return match failure {
602 TrustFailure::MalformedVersion(_) => {
606 Err(ClassificationError::UntrustedVersion(failure.to_string()).to_string())
607 }
608 TrustFailure::MissingVersionHeader | TrustFailure::UnvalidatedRevision(_) => {
611 if strict {
612 Err(ClassificationError::UntrustedVersion(failure.to_string()).to_string())
613 } else {
614 Ok(Classification::FromBody)
615 }
616 }
617 };
618 }
619
620 let classified = classify_protocol_headers(version, method, name).map_err(|e| e.to_string())?;
624 Ok(Classification::FromHeaders(ClassifiedCall {
625 call: Call::new(
626 classified.method,
627 classified.name.map(std::borrow::Cow::into_owned),
628 ),
629 }))
630}
631
632fn optional_header_str<'a>(headers: &'a HeaderMap, name: &str) -> Result<Option<&'a str>, String> {
633 unique_header(headers, name)?
634 .map(|value| {
635 value
636 .to_str()
637 .map_err(|_| format!("{name} is not a visible ASCII header value"))
638 })
639 .transpose()
640}
641
642fn unique_header<'a>(
643 headers: &'a HeaderMap,
644 name: &str,
645) -> Result<Option<&'a http::HeaderValue>, String> {
646 let mut values = headers.get_all(name).iter();
647 let value = values.next();
648 if values.next().is_some() {
649 return Err(format!("multiple {name} headers are not allowed"));
650 }
651 Ok(value)
652}
653
654#[derive(Debug, Clone, Copy, PartialEq, Eq)]
656pub enum CredentialError {
657 Missing,
659 Invalid,
661}
662
663pub fn extract_api_key(headers: &HeaderMap) -> Result<&str, CredentialError> {
668 let direct =
669 unique_header(headers, crate::API_KEY_HEADER).map_err(|_| CredentialError::Invalid)?;
670 let authorization =
671 unique_header(headers, AUTHORIZATION.as_str()).map_err(|_| CredentialError::Invalid)?;
672 let value = match (direct, authorization) {
673 (None, None) => return Err(CredentialError::Missing),
674 (Some(_), Some(_)) => return Err(CredentialError::Invalid),
675 (Some(value), None) => value.to_str().map_err(|_| CredentialError::Invalid)?,
676 (None, Some(value)) => bearer_token(value.to_str().map_err(|_| CredentialError::Invalid)?)
677 .ok_or(CredentialError::Invalid)?,
678 };
679 if value.is_empty() {
680 return Err(CredentialError::Invalid);
681 }
682 Ok(value)
683}
684
685fn bearer_token(value: &str) -> Option<&str> {
694 let (scheme, token) = value.split_once(' ')?;
695 scheme
696 .eq_ignore_ascii_case("bearer")
697 .then(|| token.trim_start_matches(' '))
698}
699
700fn refuse_credential(config: &EdgeConfig, message: &str) -> Response<MeterBody> {
707 if let Some(limit) = config.auth_failure_limit.as_ref() {
708 if limit.is_exhausted() {
709 config.metrics.throttled();
710 return error_response(
711 StatusCode::TOO_MANY_REQUESTS,
712 "TOO_MANY_FAILED_CREDENTIALS",
713 "too many failed authentication attempts",
714 );
715 }
716 limit.record_failure();
717 }
718 config.metrics.unauthenticated();
719 error_response(StatusCode::UNAUTHORIZED, "UNAUTHENTICATED", message)
720}
721
722fn error_response(status: StatusCode, code: &str, message: &str) -> Response<MeterBody> {
723 let bytes = Bytes::from(json!({"error":{"code":code,"message":message}}).to_string());
724 let mut response = Response::new(
725 Full::new(bytes)
726 .map_err(|never: Infallible| -> BoxError { match never {} })
727 .boxed_unsync(),
728 );
729 *response.status_mut() = status;
730 let headers = response.headers_mut();
731 headers.insert(
732 CONTENT_TYPE,
733 http::HeaderValue::from_static("application/json"),
734 );
735 headers.insert(
738 X_CONTENT_TYPE_OPTIONS,
739 http::HeaderValue::from_static("nosniff"),
740 );
741 headers.insert(CACHE_CONTROL, http::HeaderValue::from_static("no-store"));
742 response
743}
744
745fn cached_response(
746 cached: &CachedResponse,
747 body: String,
748 completion: Completion,
749) -> Response<MeterBody> {
750 let shares_public = completion.config.share_public_cache;
751 let observed = ObservedBody::new(Full::new(Bytes::from(body)), completion);
752 let mut response = Response::new(
753 observed
754 .map_err(|never: Infallible| -> BoxError { match never {} })
755 .boxed_unsync(),
756 );
757 *response.status_mut() = cached.status;
758 *response.version_mut() = cached.version;
759 *response.headers_mut() = cached.headers.clone();
760 let headers = response.headers_mut();
761 headers.insert("x-mcp-usage-cache", http::HeaderValue::from_static("hit"));
762 headers.insert(
763 X_CONTENT_TYPE_OPTIONS,
764 http::HeaderValue::from_static("nosniff"),
765 );
766 if !shares_public {
767 headers.insert(CACHE_CONTROL, http::HeaderValue::from_static("private"));
770 }
771 response
772}
773
774fn cacheable_headers(headers: &HeaderMap) -> HeaderMap {
775 let mut kept = HeaderMap::new();
776 if let Some(content_type) = headers.get(CONTENT_TYPE) {
777 kept.insert(CONTENT_TYPE, content_type.clone());
778 }
779 kept
780}
781
782struct Completion {
783 config: Arc<EdgeConfig>,
784 tenant: Arc<Tenant>,
785 authorization_context: String,
786 legacy: bool,
788 call: Call,
789 request: RequestMetadata,
790 response_status: StatusCode,
791 response_version: http::Version,
792 response_headers: HeaderMap,
793 content_type: String,
794}
795
796impl Completion {
797 #[expect(
798 clippy::too_many_lines,
799 reason = "terminal accounting stays linear so fail-free ordering remains auditable"
800 )]
801 async fn finish(self, bytes: Bytes) {
802 let body = match terminal_response(&self.content_type, &bytes) {
803 Ok(body) => body,
804 Err(reason) => {
805 self.config.metrics.unrecognized_response(reason);
806 return;
807 }
808 };
809 let response = mcp_usage_core::peek::response(&body);
810 let response = if self.legacy {
813 response.with_legacy_delivery(&self.call.method)
814 } else {
815 response
816 };
817
818 let response_task_id = response
819 .task
820 .as_ref()
821 .and_then(|task| task.task_id.as_deref());
822 let requested_task_id = self.request.requested_task_id.as_deref();
823 if matches!(self.call.method, Method::TasksGet)
824 && let (Some(requested), Some(returned)) = (requested_task_id, response_task_id)
825 && requested != returned
826 {
827 self.config
830 .metrics
831 .unrecognized_response(UnaccountedReason::TaskIdMismatch);
832 return;
833 }
834
835 if matches!(response.result_type, ResultType::Task)
836 && let Some(task_id) = response
837 .task
838 .as_ref()
839 .and_then(|task| task.task_id.as_deref())
840 && let Err(error) = self
841 .config
842 .tasks
843 .insert(
844 &self.tenant.id,
845 task_id,
846 TaskAttribution::from_call(&self.call, &self.tenant.prices),
847 )
848 .await
849 {
850 self.config.metrics.record_failure();
851 tracing::error!(error = %error, "failed to retain MCP task attribution");
852 }
853
854 let cleanup_task_id = response_task_id.or(requested_task_id);
855 let terminal_task = response.task.as_ref().is_some_and(|task| {
856 matches!(
857 task.status,
858 TaskStatus::Completed | TaskStatus::Failed | TaskStatus::Cancelled
859 )
860 });
861 let completed_task = response
862 .task
863 .as_ref()
864 .is_some_and(|task| matches!(task.status, TaskStatus::Completed));
865 let task_origin =
866 if matches!(self.call.method, Method::TasksGet) && completed_task && !response.is_error
867 {
868 if let Some(task_id) = response_task_id {
869 match self.config.tasks.claim(&self.tenant.id, task_id).await {
870 Ok(origin) => origin,
871 Err(error) => {
872 self.config.metrics.record_failure();
873 tracing::error!(error = %error, "failed to claim MCP task attribution");
874 None
875 }
876 }
877 } else {
878 None
879 }
880 } else {
881 None
882 };
883 let charge = decide_with_task_attribution(
884 &self.call,
885 &response,
886 &self.tenant.prices,
887 task_origin.as_ref(),
888 );
889 match charge {
890 Charge::Billable(billable) => {
891 let usage = UsageEvent::now(
892 &self.tenant.id,
893 &self.tenant.billing_customer_id,
894 &self.config.meter_name,
895 billable.units,
896 billable.idempotency_key,
897 );
898 match self.config.recorder.record(usage) {
899 Ok(RecordOutcome::Recorded | RecordOutcome::ZeroUnits) => {
900 self.config.metrics.billed(billable.units);
901 }
902 Ok(RecordOutcome::Duplicate) => {
903 self.config.metrics.duplicate();
904 }
905 Err(error) => {
906 self.config.metrics.record_failure();
907 tracing::error!(error = %error, "failed to buffer MCP usage");
908 if let (Some(task_id), Some(origin)) = (response_task_id, task_origin)
909 && let Err(store_error) = self
910 .config
911 .tasks
912 .insert(&self.tenant.id, task_id, origin)
913 .await
914 {
915 self.config.metrics.record_failure();
916 tracing::error!(
917 error = %store_error,
918 "failed to restore MCP task attribution after recorder rejection"
919 );
920 }
921 }
922 }
923 }
924 Charge::Free(reason) => {
925 self.config.metrics.free(reason);
926 if let (Some(task_id), Some(origin)) = (response_task_id, task_origin)
933 && let Err(error) = self
934 .config
935 .tasks
936 .insert(&self.tenant.id, task_id, origin)
937 .await
938 {
939 self.config.metrics.record_failure();
940 tracing::error!(
941 error = %error,
942 "failed to restore MCP task attribution after a free verdict"
943 );
944 }
945 if terminal_task
946 && !completed_task
947 && let Some(task_id) = cleanup_task_id
948 && let Err(error) = self.config.tasks.remove(&self.tenant.id, task_id).await
949 {
950 self.config.metrics.record_failure();
951 tracing::error!(error = %error, "failed to remove MCP task attribution");
952 }
953 }
954 }
955
956 if self.response_status.is_success()
957 && !self.request.is_continuation
958 && has_media_type(&self.content_type, "application/json")
959 && let Some(key) = self.request.cache_key
960 && let Some((ttl, scope)) = cache_hints(&body)
961 {
962 self.config.cache.insert(
963 key,
964 &self.authorization_context,
965 scope,
966 ttl,
967 CachedResponse {
968 status: self.response_status,
969 version: self.response_version,
970 headers: self.response_headers,
971 body,
972 },
973 );
974 }
975 }
976}
977
978struct ObservedBody<B> {
979 inner: Pin<Box<B>>,
980 completion: Option<Completion>,
981 finishing: Option<Pin<Box<dyn Future<Output = ()> + Send>>>,
982 captured: BytesMut,
983 overflowed: bool,
984 metrics: Arc<EdgeMetrics>,
985 deferred: Arc<DeferredCompletions>,
986}
987
988impl<B> ObservedBody<B> {
989 fn new(inner: B, completion: Completion) -> Self {
990 let metrics = completion.config.metrics.clone();
991 let deferred = completion.config.deferred.clone();
992 Self {
993 inner: Box::pin(inner),
994 completion: Some(completion),
995 finishing: None,
996 captured: BytesMut::new(),
997 overflowed: false,
998 metrics,
999 deferred,
1000 }
1001 }
1002
1003 fn start_completion(&mut self) -> Option<Pin<Box<dyn Future<Output = ()> + Send>>> {
1008 let completion = self.completion.take()?;
1009 if self.overflowed {
1010 completion
1011 .config
1012 .metrics
1013 .unrecognized_response(UnaccountedReason::Oversized);
1014 return None;
1015 }
1016 let captured = std::mem::take(&mut self.captured).freeze();
1017 Some(Box::pin(completion.finish(captured)))
1018 }
1019}
1020
1021impl<B> Drop for ObservedBody<B> {
1022 fn drop(&mut self) {
1040 let mut context = Context::from_waker(std::task::Waker::noop());
1041 let unfinished = if let Some(mut finishing) = self.finishing.take() {
1042 finishing
1043 .as_mut()
1044 .poll(&mut context)
1045 .is_pending()
1046 .then_some(finishing)
1047 } else if let Some(mut finishing) = self.start_completion() {
1048 finishing
1049 .as_mut()
1050 .poll(&mut context)
1051 .is_pending()
1052 .then_some(finishing)
1053 } else {
1054 None
1055 };
1056 if let Some(finishing) = unfinished {
1057 self.metrics.deferred();
1058 if !self.deferred.park(finishing) {
1059 self.metrics.record_failure();
1061 }
1062 }
1063 }
1064}
1065
1066impl<B> Body for ObservedBody<B>
1067where
1068 B: Body<Data = Bytes>,
1069{
1070 type Data = Bytes;
1071 type Error = B::Error;
1072
1073 fn poll_frame(
1074 mut self: Pin<&mut Self>,
1075 cx: &mut Context<'_>,
1076 ) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
1077 if let Some(finishing) = self.finishing.as_mut() {
1078 return match finishing.as_mut().poll(cx) {
1079 Poll::Ready(()) => {
1080 self.finishing = None;
1081 Poll::Ready(None)
1082 }
1083 Poll::Pending => Poll::Pending,
1084 };
1085 }
1086 match self.inner.as_mut().poll_frame(cx) {
1087 Poll::Ready(Some(Ok(frame))) => {
1088 if let Some(data) = frame.data_ref()
1089 && !self.overflowed
1090 {
1091 let limit = self
1092 .completion
1093 .as_ref()
1094 .map_or(0, |completion| completion.config.max_response_capture);
1095 if self.captured.len().saturating_add(data.remaining()) <= limit {
1096 self.captured.extend_from_slice(data.chunk());
1097 } else {
1098 self.captured.clear();
1099 self.overflowed = true;
1100 }
1101 }
1102 Poll::Ready(Some(Ok(frame)))
1103 }
1104 Poll::Ready(None) => {
1105 if let Some(mut finishing) = self.start_completion() {
1106 return match finishing.as_mut().poll(cx) {
1107 Poll::Ready(()) => Poll::Ready(None),
1108 Poll::Pending => {
1109 self.finishing = Some(finishing);
1110 Poll::Pending
1111 }
1112 };
1113 }
1114 Poll::Ready(None)
1115 }
1116 other => other,
1117 }
1118 }
1119
1120 fn is_end_stream(&self) -> bool {
1121 self.inner.is_end_stream()
1122 }
1123
1124 fn size_hint(&self) -> http_body::SizeHint {
1125 self.inner.size_hint()
1126 }
1127}
1128
1129fn is_json_rpc_response(value: &Value) -> bool {
1135 value.get("result").is_some() || value.get("error").is_some()
1136}
1137
1138pub(crate) fn terminal_response(
1161 content_type: &str,
1162 bytes: &[u8],
1163) -> Result<Value, UnaccountedReason> {
1164 if has_media_type(content_type, "application/json") {
1165 return serde_json::from_slice(bytes)
1166 .ok()
1167 .filter(is_json_rpc_response)
1168 .ok_or(UnaccountedReason::Unparseable);
1169 }
1170 if !has_media_type(content_type, "text/event-stream") {
1171 return Err(UnaccountedReason::MediaType);
1172 }
1173 let Ok(text) = std::str::from_utf8(bytes) else {
1174 return Err(UnaccountedReason::Unparseable);
1175 };
1176 let mut terminal = None;
1180 let mut data = String::new();
1181 for line in sse_lines(text) {
1182 if line.is_empty() {
1183 take_terminal(&data, &mut terminal);
1185 data.clear();
1186 continue;
1187 }
1188 if let Some(value) = line.strip_prefix("data:") {
1189 if !data.is_empty() {
1190 data.push('\n');
1191 }
1192 data.push_str(value.trim_start());
1193 }
1194 }
1195 take_terminal(&data, &mut terminal);
1196 terminal.ok_or(UnaccountedReason::Unparseable)
1197}
1198
1199fn take_terminal(data: &str, terminal: &mut Option<Value>) {
1204 if data.is_empty() {
1205 return;
1206 }
1207 if let Ok(value) = serde_json::from_str::<Value>(data)
1208 && is_json_rpc_response(&value)
1209 {
1210 *terminal = Some(value);
1211 }
1212}
1213
1214fn sse_lines(text: &str) -> impl Iterator<Item = &str> {
1220 let mut rest = Some(text);
1221 std::iter::from_fn(move || {
1222 let remaining = rest?;
1223 let Some(at) = remaining.find(['\n', '\r']) else {
1224 rest = None;
1225 return Some(remaining);
1226 };
1227 let (line, after) = remaining.split_at(at);
1228 let terminator = if after.starts_with("\r\n") { 2 } else { 1 };
1229 rest = Some(&after[terminator..]);
1230 Some(line)
1231 })
1232}
1233
1234fn has_media_type(content_type: &str, expected: &str) -> bool {
1235 content_type
1236 .split(';')
1237 .next()
1238 .is_some_and(|media_type| media_type.trim().eq_ignore_ascii_case(expected))
1239}
1240
1241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1242enum RequestBodyError {
1243 TooLarge,
1244 Read,
1245}
1246
1247async fn collect_request_body<B>(body: B, limit: usize) -> Result<Bytes, RequestBodyError>
1248where
1249 B: Body<Data = Bytes>,
1250{
1251 let initial_capacity = usize::try_from(body.size_hint().lower())
1252 .unwrap_or(limit)
1253 .min(limit);
1254 let mut captured = BytesMut::with_capacity(initial_capacity);
1255 let mut body = Box::pin(body);
1256 while let Some(frame) = body.as_mut().frame().await {
1257 let frame = frame.map_err(|_| RequestBodyError::Read)?;
1258 let Ok(data) = frame.into_data() else {
1259 continue;
1260 };
1261 let Some(next_len) = captured.len().checked_add(data.len()) else {
1262 return Err(RequestBodyError::TooLarge);
1263 };
1264 if next_len > limit {
1265 return Err(RequestBodyError::TooLarge);
1266 }
1267 captured.extend_from_slice(&data);
1268 }
1269 Ok(captured.freeze())
1270}
1271
1272#[cfg(test)]
1274pub(crate) mod testing {
1275 use serde_json::Value;
1276
1277 pub(crate) fn terminal_response(content_type: &str, bytes: &[u8]) -> Option<Value> {
1283 super::terminal_response(content_type, bytes).ok()
1284 }
1285}
1286
1287#[cfg(test)]
1288mod tests {
1289 use super::*;
1290 use crate::{InMemoryTenantStore, Tenant};
1291 use mcp_usage_core::{FreeReason, PriceBook};
1292 use mcp_usage_export::{
1293 AggregatedUsage, BatchExporter, BillingPipeline, ExportFuture, RecordError, UsageRecorder,
1294 };
1295 use tower::{ServiceBuilder, ServiceExt, service_fn};
1296
1297 #[derive(Debug, Default)]
1298 struct CaptureExporter {
1299 exported: std::sync::Mutex<Vec<AggregatedUsage>>,
1300 }
1301
1302 impl CaptureExporter {
1303 fn exported(&self) -> Vec<AggregatedUsage> {
1304 self.exported
1305 .lock()
1306 .unwrap_or_else(std::sync::PoisonError::into_inner)
1307 .clone()
1308 }
1309 }
1310
1311 impl BatchExporter for CaptureExporter {
1312 fn export<'a>(&'a self, batch: &'a [AggregatedUsage]) -> ExportFuture<'a> {
1313 Box::pin(async move {
1314 self.exported
1315 .lock()
1316 .unwrap_or_else(std::sync::PoisonError::into_inner)
1317 .extend_from_slice(batch);
1318 Ok(())
1319 })
1320 }
1321 }
1322
1323 #[derive(Debug, Default)]
1324 struct RejectOnceRecorder {
1325 rejected: std::sync::atomic::AtomicBool,
1326 recorded: std::sync::Mutex<Vec<UsageEvent>>,
1327 }
1328
1329 impl UsageRecorder for RejectOnceRecorder {
1330 fn record(&self, event: UsageEvent) -> Result<RecordOutcome, RecordError> {
1331 if !self
1332 .rejected
1333 .swap(true, std::sync::atomic::Ordering::SeqCst)
1334 {
1335 return Err(RecordError::Poisoned);
1336 }
1337 self.recorded
1338 .lock()
1339 .unwrap_or_else(std::sync::PoisonError::into_inner)
1340 .push(event);
1341 Ok(RecordOutcome::Recorded)
1342 }
1343 }
1344
1345 fn request(method: &str, name: Option<&str>, body: &Value, key: &str) -> Request<Full<Bytes>> {
1346 let mut body = body.clone();
1347 if let Some(object) = body.as_object_mut() {
1348 object
1349 .entry("jsonrpc")
1350 .or_insert_with(|| Value::String("2.0".to_owned()));
1351 object
1352 .entry("method")
1353 .or_insert_with(|| Value::String(method.to_owned()));
1354 }
1355 let mut builder = Request::builder()
1356 .header(PROTOCOL_VERSION_HEADER, "2026-07-28")
1357 .header(METHOD_HEADER, method)
1358 .header(crate::API_KEY_HEADER, key);
1359 if let Some(name) = name {
1360 builder = builder.header(NAME_HEADER, name);
1361 }
1362 builder
1363 .body(Full::new(Bytes::from(body.to_string())))
1364 .unwrap()
1365 }
1366
1367 fn response(body: &Value) -> Response<Full<Bytes>> {
1368 Response::builder()
1369 .header(CONTENT_TYPE, "application/json")
1370 .body(Full::new(Bytes::from(serde_json::to_vec(&body).unwrap())))
1371 .unwrap()
1372 }
1373
1374 async fn consume(response: Response<MeterBody>) -> Bytes {
1375 response.into_body().collect().await.unwrap().to_bytes()
1376 }
1377
1378 fn release_without_end_of_stream(response: Response<MeterBody>) {
1381 let mut body = Box::pin(response.into_body());
1382 let mut context = Context::from_waker(std::task::Waker::noop());
1383 let _ = body.as_mut().poll_frame(&mut context);
1384 drop(body);
1385 }
1386
1387 struct YieldOnce(bool);
1389
1390 impl Future for YieldOnce {
1391 type Output = ();
1392 fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<()> {
1393 if self.0 {
1394 Poll::Ready(())
1395 } else {
1396 self.0 = true;
1397 context.waker().wake_by_ref();
1398 Poll::Pending
1399 }
1400 }
1401 }
1402
1403 #[derive(Debug, Default)]
1406 struct YieldingTaskStore(InMemoryTaskStore);
1407
1408 impl TaskAttributionStore for YieldingTaskStore {
1409 fn insert<'a>(
1410 &'a self,
1411 tenant_id: &'a str,
1412 task_id: &'a str,
1413 attribution: TaskAttribution,
1414 ) -> crate::TaskStoreFuture<'a, ()> {
1415 Box::pin(async move {
1416 YieldOnce(false).await;
1417 self.0.insert(tenant_id, task_id, attribution).await
1418 })
1419 }
1420 fn get<'a>(
1421 &'a self,
1422 tenant_id: &'a str,
1423 task_id: &'a str,
1424 ) -> crate::TaskStoreFuture<'a, Option<TaskAttribution>> {
1425 Box::pin(async move {
1426 YieldOnce(false).await;
1427 self.0.get(tenant_id, task_id).await
1428 })
1429 }
1430 fn claim<'a>(
1431 &'a self,
1432 tenant_id: &'a str,
1433 task_id: &'a str,
1434 ) -> crate::TaskStoreFuture<'a, Option<TaskAttribution>> {
1435 Box::pin(async move {
1436 YieldOnce(false).await;
1437 self.0.claim(tenant_id, task_id).await
1438 })
1439 }
1440 fn remove<'a>(
1441 &'a self,
1442 tenant_id: &'a str,
1443 task_id: &'a str,
1444 ) -> crate::TaskStoreFuture<'a, ()> {
1445 Box::pin(async move {
1446 YieldOnce(false).await;
1447 self.0.remove(tenant_id, task_id).await
1448 })
1449 }
1450 }
1451
1452 async fn run_task_lifecycle<S>(service: S)
1455 where
1456 S: tower::Service<Request<Full<Bytes>>, Response = Response<MeterBody>, Error = Infallible>
1457 + Clone
1458 + Send
1459 + 'static,
1460 S::Future: Send,
1461 {
1462 release_without_end_of_stream(
1463 service
1464 .clone()
1465 .oneshot(request(
1466 "tools/call",
1467 Some("long_job"),
1468 &json!({"id":1,"params":{"name":"long_job"}}),
1469 "secret",
1470 ))
1471 .await
1472 .unwrap(),
1473 );
1474 release_without_end_of_stream(
1475 service
1476 .oneshot(request(
1477 "tasks/get",
1478 None,
1479 &json!({"id":2,"params":{"taskId":"task-1"}}),
1480 "secret",
1481 ))
1482 .await
1483 .unwrap(),
1484 );
1485 }
1486
1487 fn task_lifecycle_service(
1488 config: EdgeConfig,
1489 ) -> impl tower::Service<
1490 Request<Full<Bytes>>,
1491 Response = Response<MeterBody>,
1492 Error = Infallible,
1493 Future = impl Send,
1494 > + Clone
1495 + Send
1496 + 'static {
1497 let stage = Arc::new(std::sync::atomic::AtomicU64::new(0));
1498 ServiceBuilder::new()
1499 .layer(MeterLayer::new(config))
1500 .service(service_fn(move |_request: Request<Full<Bytes>>| {
1501 let n = stage.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1502 async move {
1503 let result = if n == 0 {
1504 json!({"resultType":"task","taskId":"task-1","status":"working"})
1505 } else {
1506 json!({"resultType":"complete","taskId":"task-1","status":"completed","result":{}})
1507 };
1508 Ok::<_, Infallible>(response(&json!({"jsonrpc":"2.0","id":n,"result":result})))
1509 }
1510 }))
1511 }
1512
1513 fn task_tenants() -> Arc<InMemoryTenantStore> {
1514 let tenants = Arc::new(InMemoryTenantStore::new());
1515 tenants.insert_unchecked(
1516 "secret",
1517 Tenant::new("acme", "cus_acme")
1518 .with_prices(PriceBook::flat(1).with_name("long_job", 50)),
1519 );
1520 tenants
1521 }
1522
1523 #[tokio::test]
1524 async fn a_store_that_yields_is_parked_rather_than_abandoned() {
1525 let config = EdgeConfig::new(task_tenants())
1530 .with_task_store(Arc::new(YieldingTaskStore::default()))
1531 .with_deferred_drain_per_request(0);
1532 let metrics = config.metrics();
1533 let deferred = config.deferred();
1534
1535 run_task_lifecycle(task_lifecycle_service(config)).await;
1536
1537 assert_eq!(
1538 metrics.snapshot().billed,
1539 0,
1540 "nothing can be billed while the store has not answered"
1541 );
1542 assert!(
1543 !deferred.is_empty(),
1544 "the unfinished accounting must be parked"
1545 );
1546 assert_eq!(deferred.dropped(), 0);
1547
1548 assert!(deferred.drain().await > 0);
1550
1551 let snapshot = metrics.snapshot();
1552 assert_eq!(snapshot.billed, 1, "the completed task must be charged");
1553 assert_eq!(
1554 snapshot.billed_units, 50,
1555 "priced from the originating call"
1556 );
1557 assert_eq!(snapshot.record_failures, 0);
1558 assert!(deferred.is_empty());
1559 }
1560
1561 #[tokio::test]
1562 async fn a_later_request_drains_parked_accounting_on_its_own() {
1563 let config =
1566 EdgeConfig::new(task_tenants()).with_task_store(Arc::new(YieldingTaskStore::default()));
1567 let metrics = config.metrics();
1568 let deferred = config.deferred();
1569 let service = task_lifecycle_service(config);
1570
1571 run_task_lifecycle(service.clone()).await;
1572 assert_eq!(metrics.snapshot().billed, 0);
1573
1574 for id in 3..8 {
1576 release_without_end_of_stream(
1577 service
1578 .clone()
1579 .oneshot(request(
1580 "tools/list",
1581 None,
1582 &json!({"id":id,"params":{}}),
1583 "secret",
1584 ))
1585 .await
1586 .unwrap(),
1587 );
1588 }
1589
1590 assert_eq!(
1591 metrics.snapshot().billed,
1592 1,
1593 "subsequent requests must complete the parked accounting"
1594 );
1595 assert_eq!(metrics.snapshot().billed_units, 50);
1596 assert!(deferred.is_empty());
1597 }
1598
1599 #[tokio::test]
1600 async fn malformed_and_unauthenticated_requests_do_not_poll_deferred_work() {
1601 let tenants = Arc::new(InMemoryTenantStore::new());
1602 tenants.insert_unchecked("secret", Tenant::new("acme", "cus_acme"));
1603 let config = EdgeConfig::new(tenants);
1604 let deferred = config.deferred();
1605 let polled = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1606 assert!(deferred.park(Box::pin({
1607 let polled = Arc::clone(&polled);
1608 async move {
1609 polled.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1610 }
1611 })));
1612 let service = ServiceBuilder::new()
1613 .layer(MeterLayer::new(config))
1614 .service(service_fn(|_request: Request<Full<Bytes>>| async {
1615 Ok::<_, Infallible>(response(&json!({
1616 "jsonrpc":"2.0","id":1,"result":{"resultType":"complete"}
1617 })))
1618 }));
1619
1620 let mut malformed = request("tools/list", None, &json!({"id":1,"params":{}}), "secret");
1621 malformed.headers_mut().insert(
1622 PROTOCOL_VERSION_HEADER,
1623 http::HeaderValue::from_static("invalid"),
1624 );
1625 assert_eq!(
1626 service.clone().oneshot(malformed).await.unwrap().status(),
1627 StatusCode::BAD_REQUEST
1628 );
1629 assert_eq!(polled.load(std::sync::atomic::Ordering::Relaxed), 0);
1630
1631 assert_eq!(
1632 service
1633 .clone()
1634 .oneshot(request(
1635 "tools/list",
1636 None,
1637 &json!({"id":2,"params":{}}),
1638 "wrong",
1639 ))
1640 .await
1641 .unwrap()
1642 .status(),
1643 StatusCode::UNAUTHORIZED
1644 );
1645 assert_eq!(polled.load(std::sync::atomic::Ordering::Relaxed), 0);
1646 assert_eq!(deferred.len(), 1);
1647
1648 consume(
1649 service
1650 .oneshot(request(
1651 "tools/list",
1652 None,
1653 &json!({"id":3,"params":{}}),
1654 "secret",
1655 ))
1656 .await
1657 .unwrap(),
1658 )
1659 .await;
1660 assert_eq!(polled.load(std::sync::atomic::Ordering::Relaxed), 1);
1661 assert!(deferred.is_empty());
1662 }
1663
1664 #[tokio::test]
1665 async fn recorder_rejection_restores_claimed_task_origin_for_a_later_poll() {
1666 let tasks = Arc::new(InMemoryTaskStore::new());
1667 let recorder = Arc::new(RejectOnceRecorder::default());
1668 let metrics_config = EdgeConfig::new(task_tenants())
1669 .with_task_store(tasks.clone())
1670 .with_recorder(recorder.clone());
1671 let metrics = metrics_config.metrics();
1672 let service = task_lifecycle_service(metrics_config);
1673
1674 release_without_end_of_stream(
1675 service
1676 .clone()
1677 .oneshot(request(
1678 "tools/call",
1679 Some("long_job"),
1680 &json!({"id":1,"params":{"name":"long_job"}}),
1681 "secret",
1682 ))
1683 .await
1684 .unwrap(),
1685 );
1686 release_without_end_of_stream(
1687 service
1688 .clone()
1689 .oneshot(request(
1690 "tasks/get",
1691 None,
1692 &json!({"id":2,"params":{"taskId":"task-1"}}),
1693 "secret",
1694 ))
1695 .await
1696 .unwrap(),
1697 );
1698 assert_eq!(
1699 tasks.len(),
1700 1,
1701 "the rejected charge must restore its origin"
1702 );
1703 assert!(recorder.recorded.lock().unwrap().is_empty());
1704
1705 release_without_end_of_stream(
1706 service
1707 .oneshot(request(
1708 "tasks/get",
1709 None,
1710 &json!({"id":3,"params":{"taskId":"task-1"}}),
1711 "secret",
1712 ))
1713 .await
1714 .unwrap(),
1715 );
1716 assert!(tasks.is_empty());
1717 let captured = recorder.recorded.lock().unwrap();
1718 assert_eq!(captured.len(), 1);
1719 assert_eq!(captured[0].units, 50);
1720 let snapshot = metrics.snapshot();
1721 assert_eq!(snapshot.billed, 1);
1722 assert_eq!(snapshot.record_failures, 1);
1723 }
1724
1725 #[tokio::test]
1726 async fn a_free_verdict_puts_back_the_attribution_it_claimed() {
1727 let tasks = Arc::new(InMemoryTaskStore::new());
1733 let config = EdgeConfig::new(task_tenants()).with_task_store(tasks.clone());
1734 let stage = Arc::new(std::sync::atomic::AtomicU64::new(0));
1735 let service = ServiceBuilder::new()
1736 .layer(MeterLayer::new(config))
1737 .service(service_fn(move |_request: Request<Full<Bytes>>| {
1738 let n = stage.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1739 async move {
1740 let result = match n {
1741 0 => json!({"resultType":"task","taskId":"task-1","status":"working"}),
1742 1 => json!({"resultType":"task","taskId":"task-1","status":"completed"}),
1744 _ => json!({
1745 "resultType":"complete",
1746 "taskId":"task-1",
1747 "status":"completed",
1748 "result":{}
1749 }),
1750 };
1751 Ok::<_, Infallible>(response(&json!({
1752 "jsonrpc":"2.0","id":n,"result":result
1753 })))
1754 }
1755 }));
1756
1757 release_without_end_of_stream(
1758 service
1759 .clone()
1760 .oneshot(request(
1761 "tools/call",
1762 Some("long_job"),
1763 &json!({"id":1,"params":{"name":"long_job"}}),
1764 "secret",
1765 ))
1766 .await
1767 .unwrap(),
1768 );
1769 assert_eq!(tasks.len(), 1, "the creation stored an attribution");
1770
1771 release_without_end_of_stream(
1772 service
1773 .clone()
1774 .oneshot(request(
1775 "tasks/get",
1776 None,
1777 &json!({"id":2,"params":{"taskId":"task-1"}}),
1778 "secret",
1779 ))
1780 .await
1781 .unwrap(),
1782 );
1783 assert_eq!(
1784 tasks.len(),
1785 1,
1786 "a free verdict must not consume the attribution"
1787 );
1788
1789 release_without_end_of_stream(
1791 service
1792 .clone()
1793 .oneshot(request(
1794 "tasks/get",
1795 None,
1796 &json!({"id":3,"params":{"taskId":"task-1"}}),
1797 "secret",
1798 ))
1799 .await
1800 .unwrap(),
1801 );
1802 assert!(tasks.is_empty(), "the completing poll consumed it");
1803 }
1804
1805 #[tokio::test]
1806 async fn an_idle_legacy_client_is_not_billed_for_lifecycle_traffic() {
1807 let tenants = Arc::new(InMemoryTenantStore::new());
1813 tenants.insert_unchecked("secret", Tenant::new("acme", "cus_acme"));
1814 let billing = Arc::new(BillingPipeline::new(CaptureExporter::default()));
1815 let config = EdgeConfig::new(tenants).with_recorder(billing.clone());
1816 let metrics = config.metrics();
1817 let service = ServiceBuilder::new()
1818 .layer(MeterLayer::new(config))
1819 .service(service_fn(|_request: Request<Full<Bytes>>| async {
1820 Ok::<_, Infallible>(response(&json!({"jsonrpc":"2.0","id":1,"result":{}})))
1821 }));
1822
1823 for method in [
1824 "ping",
1825 "initialize",
1826 "logging/setLevel",
1827 "resources/subscribe",
1828 ] {
1829 let legacy = Request::builder()
1830 .method("POST")
1831 .header(crate::API_KEY_HEADER, "secret")
1832 .body(Full::new(Bytes::from(
1833 json!({"jsonrpc":"2.0","id":1,"method":method}).to_string(),
1834 )))
1835 .unwrap();
1836 release_without_end_of_stream(service.clone().oneshot(legacy).await.unwrap());
1837 }
1838
1839 assert_eq!(
1840 metrics.snapshot().billed,
1841 0,
1842 "lifecycle traffic must not be billed on the legacy path"
1843 );
1844 }
1845
1846 #[tokio::test]
1847 async fn a_legacy_tool_call_is_still_billed_after_scoping_the_promotion() {
1848 let tenants = Arc::new(InMemoryTenantStore::new());
1851 tenants.insert_unchecked(
1852 "secret",
1853 Tenant::new("acme", "cus_acme").with_prices(PriceBook::flat(4)),
1854 );
1855 let billing = Arc::new(BillingPipeline::new(CaptureExporter::default()));
1856 let config = EdgeConfig::new(tenants).with_recorder(billing.clone());
1857 let metrics = config.metrics();
1858 let service = ServiceBuilder::new()
1859 .layer(MeterLayer::new(config))
1860 .service(service_fn(|_request: Request<Full<Bytes>>| async {
1861 Ok::<_, Infallible>(response(&json!({"jsonrpc":"2.0","id":1,"result":{}})))
1863 }));
1864
1865 let legacy = Request::builder()
1866 .method("POST")
1867 .header(crate::API_KEY_HEADER, "secret")
1868 .body(Full::new(Bytes::from(
1869 json!({"jsonrpc":"2.0","id":1,"method":"tools/call",
1870 "params":{"name":"search"}})
1871 .to_string(),
1872 )))
1873 .unwrap();
1874 release_without_end_of_stream(service.oneshot(legacy).await.unwrap());
1875
1876 assert_eq!(metrics.snapshot().billed, 1);
1877 }
1878
1879 #[tokio::test]
1880 async fn missing_completed_task_id_preserves_origin_for_a_later_valid_poll() {
1881 let tasks = Arc::new(InMemoryTaskStore::new());
1882 let config = EdgeConfig::new(task_tenants()).with_task_store(tasks.clone());
1883 let metrics = config.metrics();
1884 let stage = Arc::new(std::sync::atomic::AtomicU64::new(0));
1885 let service = ServiceBuilder::new()
1886 .layer(MeterLayer::new(config))
1887 .service(service_fn(move |_request: Request<Full<Bytes>>| {
1888 let n = stage.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1889 async move {
1890 let result = match n {
1891 0 => json!({
1892 "resultType":"task",
1893 "taskId":"task-1",
1894 "status":"working"
1895 }),
1896 1 => json!({
1897 "resultType":"complete",
1898 "status":"completed",
1899 "result":{}
1900 }),
1901 _ => json!({
1902 "resultType":"complete",
1903 "taskId":"task-1",
1904 "status":"completed",
1905 "result":{}
1906 }),
1907 };
1908 Ok::<_, Infallible>(response(&json!({
1909 "jsonrpc":"2.0",
1910 "id":n,
1911 "result":result
1912 })))
1913 }
1914 }));
1915
1916 release_without_end_of_stream(
1917 service
1918 .clone()
1919 .oneshot(request(
1920 "tools/call",
1921 Some("long_job"),
1922 &json!({"id":1,"params":{"name":"long_job"}}),
1923 "secret",
1924 ))
1925 .await
1926 .unwrap(),
1927 );
1928 release_without_end_of_stream(
1929 service
1930 .clone()
1931 .oneshot(request(
1932 "tasks/get",
1933 None,
1934 &json!({"id":2,"params":{"taskId":"task-1"}}),
1935 "secret",
1936 ))
1937 .await
1938 .unwrap(),
1939 );
1940
1941 assert_eq!(
1942 tasks.len(),
1943 1,
1944 "the malformed poll must not consume the origin"
1945 );
1946 assert_eq!(metrics.snapshot().billed, 0);
1947
1948 release_without_end_of_stream(
1949 service
1950 .oneshot(request(
1951 "tasks/get",
1952 None,
1953 &json!({"id":3,"params":{"taskId":"task-1"}}),
1954 "secret",
1955 ))
1956 .await
1957 .unwrap(),
1958 );
1959
1960 assert!(tasks.is_empty());
1961 let snapshot = metrics.snapshot();
1962 assert_eq!(snapshot.billed, 1);
1963 assert_eq!(snapshot.billed_units, 50);
1964 }
1965
1966 #[tokio::test]
1967 async fn free_verdicts_reach_the_metrics_under_their_own_reason() {
1968 let config = EdgeConfig::new(task_tenants());
1974 let metrics = config.metrics();
1975 let service = ServiceBuilder::new()
1976 .layer(MeterLayer::new(config))
1977 .service(service_fn(|_request: Request<Full<Bytes>>| async move {
1978 Ok::<_, Infallible>(response(&json!({
1979 "jsonrpc": "2.0",
1980 "id": 1,
1981 "result": {
1982 "resultType": "complete",
1983 "taskId": "task-unknown",
1984 "status": "completed",
1985 "content": []
1986 }
1987 })))
1988 }));
1989
1990 consume(
1992 service
1993 .clone()
1994 .oneshot(request("tools/list", None, &json!({"id":1}), "secret"))
1995 .await
1996 .unwrap(),
1997 )
1998 .await;
1999
2000 consume(
2002 service
2003 .oneshot(request(
2004 "tasks/get",
2005 None,
2006 &json!({"id":2,"params":{"taskId":"task-unknown"}}),
2007 "secret",
2008 ))
2009 .await
2010 .unwrap(),
2011 )
2012 .await;
2013
2014 let snapshot = metrics.snapshot();
2015 assert_eq!(snapshot.billed, 0);
2016 assert_eq!(snapshot.free, 2);
2017 assert_eq!(
2018 snapshot.free_by_reason[FreeReason::Discovery.index()],
2019 1,
2020 "the listing must land under discovery"
2021 );
2022 assert_eq!(
2023 snapshot.free_by_reason[FreeReason::MissingTaskAttribution.index()],
2024 1,
2025 "the orphaned completion must be separable from discovery"
2026 );
2027 }
2028
2029 #[tokio::test]
2030 async fn a_synchronous_store_never_parks_anything() {
2031 let config = EdgeConfig::new(task_tenants());
2034 let metrics = config.metrics();
2035 let deferred = config.deferred();
2036
2037 run_task_lifecycle(task_lifecycle_service(config)).await;
2038
2039 let snapshot = metrics.snapshot();
2040 assert_eq!(snapshot.billed, 1);
2041 assert_eq!(snapshot.billed_units, 50);
2042 assert_eq!(snapshot.deferred, 0, "nothing should have been parked");
2043 assert!(deferred.is_empty());
2044 }
2045
2046 #[tokio::test]
2050 async fn a_legacy_client_is_metered_from_its_body() {
2051 let tenants = Arc::new(InMemoryTenantStore::new());
2052 tenants.insert_unchecked(
2053 "secret",
2054 Tenant::new("acme", "cus_acme")
2055 .with_prices(PriceBook::flat(1).with_name("expensive", 25)),
2056 );
2057 let billing = Arc::new(BillingPipeline::new(CaptureExporter::default()));
2058 let config = EdgeConfig::new(tenants).with_recorder(billing.clone());
2059 let service = ServiceBuilder::new()
2060 .layer(MeterLayer::new(config))
2061 .service(service_fn(|_request: Request<Full<Bytes>>| async {
2062 Ok::<_, Infallible>(response(&json!({
2063 "jsonrpc":"2.0","id":1,
2064 "result":{"resultType":"complete","content":[]}
2065 })))
2066 }));
2067
2068 let legacy = Request::builder()
2070 .method("POST")
2071 .header(crate::API_KEY_HEADER, "secret")
2072 .body(Full::new(Bytes::from(
2073 json!({"jsonrpc":"2.0","id":1,"method":"tools/call",
2074 "params":{"name":"expensive"}})
2075 .to_string(),
2076 )))
2077 .unwrap();
2078
2079 let response = service.oneshot(legacy).await.unwrap();
2080 assert_eq!(response.status(), StatusCode::OK);
2081 consume(response).await;
2082 billing.flush().await.unwrap();
2083 assert_eq!(
2084 billing.exporter().exported()[0].units,
2085 25,
2086 "the body named the expensive tool, so the expensive price applies"
2087 );
2088 }
2089
2090 #[tokio::test]
2094 async fn a_legacy_response_without_a_result_type_is_still_billed() {
2095 let tenants = Arc::new(InMemoryTenantStore::new());
2096 tenants.insert_unchecked(
2097 "secret",
2098 Tenant::new("acme", "cus_acme").with_prices(PriceBook::flat(7)),
2099 );
2100 let billing = Arc::new(BillingPipeline::new(CaptureExporter::default()));
2101 let config = EdgeConfig::new(tenants).with_recorder(billing.clone());
2102 let service = ServiceBuilder::new()
2103 .layer(MeterLayer::new(config))
2104 .service(service_fn(|_request: Request<Full<Bytes>>| async {
2105 Ok::<_, Infallible>(response(&json!({
2107 "jsonrpc":"2.0","id":1,"result":{"content":[]}
2108 })))
2109 }));
2110
2111 let legacy = Request::builder()
2112 .method("POST")
2113 .header(crate::API_KEY_HEADER, "secret")
2114 .body(Full::new(Bytes::from(
2115 json!({"jsonrpc":"2.0","id":1,"method":"tools/call",
2116 "params":{"name":"anything"}})
2117 .to_string(),
2118 )))
2119 .unwrap();
2120
2121 let response = service.oneshot(legacy).await.unwrap();
2122 consume(response).await;
2123 billing.flush().await.unwrap();
2124 assert_eq!(
2125 billing.exporter().exported()[0].units,
2126 7,
2127 "a legacy result is delivered work and must be billed"
2128 );
2129 }
2130
2131 #[tokio::test]
2134 async fn a_modern_response_without_a_result_type_is_still_free() {
2135 let tenants = Arc::new(InMemoryTenantStore::new());
2136 tenants.insert_unchecked(
2137 "secret",
2138 Tenant::new("acme", "cus_acme").with_prices(PriceBook::flat(7)),
2139 );
2140 let billing = Arc::new(BillingPipeline::new(CaptureExporter::default()));
2141 let config = EdgeConfig::new(tenants).with_recorder(billing.clone());
2142 let service = ServiceBuilder::new()
2143 .layer(MeterLayer::new(config))
2144 .service(service_fn(|_request: Request<Full<Bytes>>| async {
2145 Ok::<_, Infallible>(response(&json!({
2146 "jsonrpc":"2.0","id":1,"result":{"content":[]}
2147 })))
2148 }));
2149
2150 let response = service
2151 .oneshot(request(
2152 "tools/call",
2153 Some("anything"),
2154 &json!({"id":1,"params":{"name":"anything"}}),
2155 "secret",
2156 ))
2157 .await
2158 .unwrap();
2159 consume(response).await;
2160 billing.flush().await.unwrap();
2161 assert!(
2162 billing.exporter().exported().is_empty(),
2163 "an unrecognized modern result must not be billed"
2164 );
2165 }
2166
2167 #[tokio::test]
2171 async fn a_legacy_client_cannot_underpay_by_lying_about_the_tool() {
2172 let tenants = Arc::new(InMemoryTenantStore::new());
2173 tenants.insert_unchecked(
2174 "secret",
2175 Tenant::new("acme", "cus_acme")
2176 .with_prices(PriceBook::flat(1).with_name("expensive", 25)),
2177 );
2178 let billing = Arc::new(BillingPipeline::new(CaptureExporter::default()));
2179 let config = EdgeConfig::new(tenants).with_recorder(billing.clone());
2180 let seen: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
2181 let observed = seen.clone();
2182 let service = ServiceBuilder::new()
2183 .layer(MeterLayer::new(config))
2184 .service(service_fn(move |request: Request<Full<Bytes>>| {
2185 let observed = observed.clone();
2186 async move {
2187 let bytes = request.into_body().collect().await.unwrap().to_bytes();
2188 let body: Value = serde_json::from_slice(&bytes).unwrap();
2189 *observed.lock().unwrap() =
2190 Some(body["params"]["name"].as_str().unwrap().into());
2191 Ok::<_, Infallible>(response(&json!({
2192 "jsonrpc":"2.0","id":1,
2193 "result":{"resultType":"complete","content":[]}
2194 })))
2195 }
2196 }));
2197
2198 let lying = Request::builder()
2202 .method("POST")
2203 .header(crate::API_KEY_HEADER, "secret")
2204 .header(METHOD_HEADER, "tools/list")
2205 .header(NAME_HEADER, "cheap")
2206 .body(Full::new(Bytes::from(
2207 json!({"jsonrpc":"2.0","id":1,"method":"tools/call",
2208 "params":{"name":"expensive"}})
2209 .to_string(),
2210 )))
2211 .unwrap();
2212
2213 let response = service.oneshot(lying).await.unwrap();
2214 assert_eq!(response.status(), StatusCode::OK);
2215 consume(response).await;
2216 billing.flush().await.unwrap();
2217 assert_eq!(
2218 billing.exporter().exported()[0].units,
2219 25,
2220 "billed for what the origin ran, not what the headers claimed"
2221 );
2222 assert_eq!(
2223 seen.lock().unwrap().as_deref(),
2224 Some("expensive"),
2225 "the origin ran the tool the body named"
2226 );
2227 }
2228
2229 #[tokio::test]
2231 async fn strict_mode_still_refuses_legacy_clients() {
2232 let tenants = Arc::new(InMemoryTenantStore::new());
2233 tenants.insert_unchecked("secret", Tenant::new("acme", "cus_acme"));
2234 let config = EdgeConfig::new(tenants).with_strict_protocol_version(true);
2235 let service = ServiceBuilder::new()
2236 .layer(MeterLayer::new(config))
2237 .service(service_fn(|_request: Request<Full<Bytes>>| async {
2238 Ok::<_, Infallible>(response(&json!({
2239 "jsonrpc":"2.0","id":1,"result":{"resultType":"complete"}
2240 })))
2241 }));
2242
2243 let legacy = Request::builder()
2244 .method("POST")
2245 .header(crate::API_KEY_HEADER, "secret")
2246 .body(Full::new(Bytes::from(
2247 json!({"jsonrpc":"2.0","id":1,"method":"tools/list"}).to_string(),
2248 )))
2249 .unwrap();
2250 assert_eq!(
2251 service.oneshot(legacy).await.unwrap().status(),
2252 StatusCode::BAD_REQUEST
2253 );
2254 }
2255
2256 #[tokio::test]
2258 async fn an_unauthenticated_legacy_request_is_refused_before_its_body_is_read() {
2259 let tenants = Arc::new(InMemoryTenantStore::new());
2260 tenants.insert_unchecked("secret", Tenant::new("acme", "cus_acme"));
2261 let config = EdgeConfig::new(tenants);
2262 let service = ServiceBuilder::new()
2263 .layer(MeterLayer::new(config))
2264 .service(service_fn(|_request: Request<Full<Bytes>>| async {
2265 Ok::<_, Infallible>(response(&json!({
2266 "jsonrpc":"2.0","id":1,"result":{"resultType":"complete"}
2267 })))
2268 }));
2269
2270 let anonymous = Request::builder()
2272 .body(Full::new(Bytes::from_static(b"{{{ not json")))
2273 .unwrap();
2274 assert_eq!(
2275 service.oneshot(anonymous).await.unwrap().status(),
2276 StatusCode::UNAUTHORIZED,
2277 "an anonymous caller must not reach the JSON parser"
2278 );
2279 }
2280
2281 #[tokio::test]
2282 async fn complete_tool_call_is_recorded_after_body_consumption() {
2283 let tenants = Arc::new(InMemoryTenantStore::new());
2284 tenants.insert_unchecked(
2285 "secret",
2286 Tenant::new("acme", "cus_acme")
2287 .with_prices(PriceBook::flat(1).with_name("expensive", 25)),
2288 );
2289 let billing = Arc::new(BillingPipeline::new(CaptureExporter::default()));
2290 let config = EdgeConfig::new(tenants).with_recorder(billing.clone());
2291 let service = ServiceBuilder::new()
2292 .layer(MeterLayer::new(config))
2293 .service(service_fn(|_request: Request<Full<Bytes>>| async {
2294 Ok::<_, Infallible>(response(&json!({
2295 "jsonrpc":"2.0","id":1,
2296 "result":{"resultType":"complete","content":[]}
2297 })))
2298 }));
2299
2300 let response = service
2301 .oneshot(request(
2302 "tools/call",
2303 Some("expensive"),
2304 &json!({"jsonrpc":"2.0","id":1,"params":{"name":"expensive"}}),
2305 "secret",
2306 ))
2307 .await
2308 .unwrap();
2309 assert_eq!(billing.pending_buckets(), 0, "not billed before delivery");
2310 consume(response).await;
2311 assert_eq!(billing.pending_buckets(), 1);
2312 billing.flush().await.unwrap();
2313 assert_eq!(billing.exporter().exported()[0].units, 25);
2314 }
2315
2316 #[tokio::test]
2317 async fn private_cache_never_crosses_authorization_contexts_and_rewrites_response_id() {
2318 let tenants = Arc::new(InMemoryTenantStore::new());
2319 tenants.insert_unchecked("key-a", Tenant::new("a", "cus_a"));
2320 tenants.insert_unchecked("key-b", Tenant::new("a", "cus_a"));
2323 let calls = Arc::new(std::sync::atomic::AtomicU64::new(0));
2324 let inner_calls = calls.clone();
2325 let config = EdgeConfig::new(tenants);
2326 let service = ServiceBuilder::new()
2327 .layer(MeterLayer::new(config))
2328 .service(service_fn(move |_request: Request<Full<Bytes>>| {
2329 inner_calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2330 async {
2331 Ok::<_, Infallible>(response(&json!({
2332 "jsonrpc":"2.0","id":1,
2333 "result":{"resultType":"complete","tools":[],"ttlMs":60000,"cacheScope":"private"}
2334 })))
2335 }
2336 }));
2337
2338 let first = service
2339 .clone()
2340 .oneshot(request(
2341 "tools/list",
2342 None,
2343 &json!({"jsonrpc":"2.0","id":1,"params":{}}),
2344 "key-a",
2345 ))
2346 .await
2347 .unwrap();
2348 consume(first).await;
2349
2350 let hit = service
2351 .clone()
2352 .oneshot(request(
2353 "tools/list",
2354 None,
2355 &json!({"jsonrpc":"2.0","id":99,"params":{}}),
2356 "key-a",
2357 ))
2358 .await
2359 .unwrap();
2360 assert_eq!(hit.headers()["x-mcp-usage-cache"], "hit");
2361 let hit_body: Value = serde_json::from_slice(&consume(hit).await).unwrap();
2362 assert_eq!(hit_body["id"], 99);
2363
2364 let other = service
2365 .oneshot(request(
2366 "tools/list",
2367 None,
2368 &json!({"jsonrpc":"2.0","id":2,"params":{}}),
2369 "key-b",
2370 ))
2371 .await
2372 .unwrap();
2373 consume(other).await;
2374 assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 2);
2375 }
2376
2377 #[tokio::test]
2378 async fn cache_hits_for_billable_resources_are_still_metered_on_delivery() {
2379 let tenants = Arc::new(InMemoryTenantStore::new());
2380 tenants.insert_unchecked(
2381 "secret",
2382 Tenant::new("acme", "cus_acme")
2383 .with_prices(PriceBook::flat(1).with_name("file:///report", 4)),
2384 );
2385 let billing = Arc::new(BillingPipeline::new(CaptureExporter::default()));
2386 let calls = Arc::new(std::sync::atomic::AtomicU64::new(0));
2387 let inner_calls = calls.clone();
2388 let service = ServiceBuilder::new()
2389 .layer(MeterLayer::new(
2390 EdgeConfig::new(tenants).with_recorder(billing.clone()),
2391 ))
2392 .service(service_fn(move |_request: Request<Full<Bytes>>| {
2393 inner_calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2394 async {
2395 Ok::<_, Infallible>(response(&json!({
2396 "jsonrpc":"2.0","id":1,
2397 "result":{
2398 "resultType":"complete",
2399 "contents":[],
2400 "ttlMs":60000,
2401 "cacheScope":"private"
2402 }
2403 })))
2404 }
2405 }));
2406
2407 for id in [1, 2] {
2408 consume(
2409 service
2410 .clone()
2411 .oneshot(request(
2412 "resources/read",
2413 Some("file:///report"),
2414 &json!({"jsonrpc":"2.0","id":id,"params":{"uri":"file:///report"}}),
2415 "secret",
2416 ))
2417 .await
2418 .unwrap(),
2419 )
2420 .await;
2421 }
2422 assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 1);
2423 billing.flush().await.unwrap();
2424 assert_eq!(billing.exporter().exported()[0].units, 8);
2425 }
2426
2427 #[tokio::test]
2428 async fn trace_context_reaches_the_rmcp_service_unchanged() {
2429 let tenants = Arc::new(InMemoryTenantStore::new());
2430 tenants.insert_unchecked("secret", Tenant::new("acme", "cus_acme"));
2431 let seen = Arc::new(std::sync::atomic::AtomicBool::new(false));
2432 let inner_seen = seen.clone();
2433 let service = ServiceBuilder::new()
2434 .layer(MeterLayer::new(EdgeConfig::new(tenants)))
2435 .service(service_fn(move |request: Request<Full<Bytes>>| {
2436 inner_seen.store(
2437 request.headers().get("traceparent").is_some_and(|value| {
2438 value == "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
2439 }),
2440 std::sync::atomic::Ordering::Relaxed,
2441 );
2442 async {
2443 Ok::<_, Infallible>(response(&json!({
2444 "jsonrpc":"2.0","id":1,"result":{"resultType":"complete"}
2445 })))
2446 }
2447 }));
2448 let mut request = request(
2449 "tools/call",
2450 Some("tool"),
2451 &json!({"id":1,"params":{"name":"tool"}}),
2452 "secret",
2453 );
2454 request.headers_mut().insert(
2455 "traceparent",
2456 http::HeaderValue::from_static(
2457 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
2458 ),
2459 );
2460 consume(service.oneshot(request).await.unwrap()).await;
2461 assert!(seen.load(std::sync::atomic::Ordering::Relaxed));
2462 }
2463
2464 #[tokio::test]
2465 async fn task_completion_uses_origin_price_and_repeat_poll_is_free() {
2466 let tenants = Arc::new(InMemoryTenantStore::new());
2467 tenants.insert_unchecked(
2468 "secret",
2469 Tenant::new("acme", "cus_acme")
2470 .with_prices(PriceBook::flat(1).with_name("long_job", 50)),
2471 );
2472 let billing = Arc::new(BillingPipeline::new(CaptureExporter::default()));
2473 let stage = Arc::new(std::sync::atomic::AtomicU64::new(0));
2474 let inner_stage = stage.clone();
2475 let service = ServiceBuilder::new()
2476 .layer(MeterLayer::new(
2477 EdgeConfig::new(tenants).with_recorder(billing.clone()),
2478 ))
2479 .service(service_fn(move |_request: Request<Full<Bytes>>| {
2480 let n = inner_stage.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2481 async move {
2482 let result = if n == 0 {
2483 json!({"resultType":"task","taskId":"task-1","status":"working"})
2484 } else {
2485 json!({"resultType":"complete","taskId":"task-1","status":"completed","result":{}})
2486 };
2487 Ok::<_, Infallible>(response(&json!({"jsonrpc":"2.0","id":n,"result":result})))
2488 }
2489 }));
2490
2491 consume(
2492 service
2493 .clone()
2494 .oneshot(request(
2495 "tools/call",
2496 Some("long_job"),
2497 &json!({"id":1,"params":{"name":"long_job"}}),
2498 "secret",
2499 ))
2500 .await
2501 .unwrap(),
2502 )
2503 .await;
2504 for id in [2, 3] {
2505 consume(
2506 service
2507 .clone()
2508 .oneshot(request(
2509 "tasks/get",
2510 None,
2511 &json!({"id":id,"params":{"taskId":"task-1"}}),
2512 "secret",
2513 ))
2514 .await
2515 .unwrap(),
2516 )
2517 .await;
2518 }
2519 billing.flush().await.unwrap();
2520 let exported = billing.exporter().exported();
2521 assert_eq!(exported.len(), 1);
2522 assert_eq!(exported[0].units, 50);
2523 }
2524
2525 #[tokio::test]
2526 async fn mismatched_task_response_cannot_bill_another_tasks_origin() {
2527 let tenants = Arc::new(InMemoryTenantStore::new());
2528 tenants.insert_unchecked(
2529 "secret",
2530 Tenant::new("acme", "cus_acme")
2531 .with_prices(PriceBook::flat(1).with_name("expensive", 100)),
2532 );
2533 let billing = Arc::new(BillingPipeline::new(CaptureExporter::default()));
2534 let stage = Arc::new(std::sync::atomic::AtomicU64::new(0));
2535 let inner_stage = stage.clone();
2536 let service = ServiceBuilder::new()
2537 .layer(MeterLayer::new(
2538 EdgeConfig::new(tenants).with_recorder(billing.clone()),
2539 ))
2540 .service(service_fn(move |_request: Request<Full<Bytes>>| {
2541 let n = inner_stage.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2542 async move {
2543 let result = match n {
2544 0 => json!({"resultType":"task","taskId":"task-1","status":"working"}),
2545 1 => json!({"resultType":"task","taskId":"task-2","status":"working"}),
2546 _ => json!({
2547 "resultType":"complete",
2548 "taskId":"task-2",
2549 "status":"completed",
2550 "result":{}
2551 }),
2552 };
2553 Ok::<_, Infallible>(response(&json!({"jsonrpc":"2.0","id":n,"result":result})))
2554 }
2555 }));
2556
2557 for (name, id) in [("cheap", 1), ("expensive", 2)] {
2558 consume(
2559 service
2560 .clone()
2561 .oneshot(request(
2562 "tools/call",
2563 Some(name),
2564 &json!({"id":id,"params":{"name":name}}),
2565 "secret",
2566 ))
2567 .await
2568 .unwrap(),
2569 )
2570 .await;
2571 }
2572 consume(
2573 service
2574 .oneshot(request(
2575 "tasks/get",
2576 None,
2577 &json!({"id":3,"params":{"taskId":"task-1"}}),
2578 "secret",
2579 ))
2580 .await
2581 .unwrap(),
2582 )
2583 .await;
2584 assert_eq!(billing.flush().await.unwrap(), 0);
2585 }
2586
2587 #[tokio::test]
2588 async fn accounting_runs_when_the_transport_stops_polling_early() {
2589 let tenants = Arc::new(InMemoryTenantStore::new());
2599 tenants.insert_unchecked(
2600 "secret",
2601 Tenant::new("acme", "cus_acme").with_prices(PriceBook::flat(1).with_name("tool", 7)),
2602 );
2603 let billing = Arc::new(BillingPipeline::new(CaptureExporter::default()));
2604 let config = EdgeConfig::new(tenants).with_recorder(billing.clone());
2605 let metrics = config.metrics();
2606 let service = ServiceBuilder::new()
2607 .layer(MeterLayer::new(config))
2608 .service(service_fn(|_request: Request<Full<Bytes>>| async {
2609 Ok::<_, Infallible>(response(&json!({
2610 "jsonrpc":"2.0","id":1,
2611 "result":{"resultType":"complete","content":[]}
2612 })))
2613 }));
2614
2615 let response = service
2616 .oneshot(request(
2617 "tools/call",
2618 Some("tool"),
2619 &json!({"id":1,"params":{"name":"tool"}}),
2620 "secret",
2621 ))
2622 .await
2623 .unwrap();
2624
2625 let mut body = Box::pin(response.into_body());
2626 let mut context = Context::from_waker(std::task::Waker::noop());
2627 assert!(
2630 matches!(
2631 body.as_mut().poll_frame(&mut context),
2632 Poll::Ready(Some(Ok(_)))
2633 ),
2634 "expected one data frame"
2635 );
2636 assert_eq!(
2637 metrics.snapshot().billed,
2638 0,
2639 "nothing should be billed before the body is released"
2640 );
2641
2642 drop(body);
2643
2644 let snapshot = metrics.snapshot();
2645 assert_eq!(
2646 snapshot.billed, 1,
2647 "the delivery must still be accounted for"
2648 );
2649 assert_eq!(snapshot.billed_units, 7);
2650 assert_eq!(snapshot.record_failures, 0);
2651 assert_eq!(billing.pending_buckets(), 1);
2652 }
2653
2654 #[tokio::test]
2655 async fn a_fully_consumed_body_is_accounted_for_exactly_once() {
2656 let tenants = Arc::new(InMemoryTenantStore::new());
2659 tenants.insert_unchecked(
2660 "secret",
2661 Tenant::new("acme", "cus_acme").with_prices(PriceBook::flat(1).with_name("tool", 5)),
2662 );
2663 let config = EdgeConfig::new(tenants);
2664 let metrics = config.metrics();
2665 let service = ServiceBuilder::new()
2666 .layer(MeterLayer::new(config))
2667 .service(service_fn(|_request: Request<Full<Bytes>>| async {
2668 Ok::<_, Infallible>(response(&json!({
2669 "jsonrpc":"2.0","id":1,
2670 "result":{"resultType":"complete","content":[]}
2671 })))
2672 }));
2673
2674 consume(
2675 service
2676 .oneshot(request(
2677 "tools/call",
2678 Some("tool"),
2679 &json!({"id":1,"params":{"name":"tool"}}),
2680 "secret",
2681 ))
2682 .await
2683 .unwrap(),
2684 )
2685 .await;
2686
2687 let snapshot = metrics.snapshot();
2688 assert_eq!(snapshot.billed, 1);
2689 assert_eq!(snapshot.billed_units, 5);
2690 }
2691
2692 #[tokio::test]
2693 async fn public_results_cross_tenants_only_when_sharing_is_enabled() {
2694 for (share, expected_origin_calls) in [(false, 2), (true, 1)] {
2699 let tenants = Arc::new(InMemoryTenantStore::new());
2700 tenants.insert_unchecked("key-a", Tenant::new("a", "cus_a"));
2701 tenants.insert_unchecked("key-b", Tenant::new("b", "cus_b"));
2702 let calls = Arc::new(std::sync::atomic::AtomicU64::new(0));
2703 let inner_calls = calls.clone();
2704 let service = ServiceBuilder::new()
2705 .layer(MeterLayer::new(
2706 EdgeConfig::new(tenants).with_public_cache_sharing(share),
2707 ))
2708 .service(service_fn(move |_request: Request<Full<Bytes>>| {
2709 inner_calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2710 async {
2711 Ok::<_, Infallible>(response(&json!({
2712 "jsonrpc":"2.0","id":1,
2713 "result":{
2714 "resultType":"complete","tools":[],
2715 "ttlMs":60000,"cacheScope":"public"
2716 }
2717 })))
2718 }
2719 }));
2720
2721 for key in ["key-a", "key-b"] {
2722 consume(
2723 service
2724 .clone()
2725 .oneshot(request(
2726 "tools/list",
2727 None,
2728 &json!({"jsonrpc":"2.0","id":1,"params":{}}),
2729 key,
2730 ))
2731 .await
2732 .unwrap(),
2733 )
2734 .await;
2735 }
2736
2737 assert_eq!(
2738 calls.load(std::sync::atomic::Ordering::Relaxed),
2739 expected_origin_calls,
2740 "with_public_cache_sharing({share})"
2741 );
2742 }
2743 }
2744
2745 #[tokio::test]
2746 async fn the_consumed_credential_does_not_reach_the_inner_service() {
2747 let tenants = Arc::new(InMemoryTenantStore::new());
2748 tenants.insert_unchecked("secret", Tenant::new("acme", "cus_acme"));
2749
2750 for (forward, expected) in [(false, false), (true, true)] {
2751 for bearer in [false, true] {
2752 let seen = Arc::new(std::sync::atomic::AtomicBool::new(false));
2753 let inner_seen = seen.clone();
2754 let service = ServiceBuilder::new()
2755 .layer(MeterLayer::new(
2756 EdgeConfig::new(tenants.clone()).with_credential_forwarding(forward),
2757 ))
2758 .service(service_fn(move |request: Request<Full<Bytes>>| {
2759 let headers = request.headers();
2760 inner_seen.store(
2761 headers.contains_key(crate::API_KEY_HEADER)
2762 || headers.contains_key(AUTHORIZATION),
2763 std::sync::atomic::Ordering::Relaxed,
2764 );
2765 async {
2766 Ok::<_, Infallible>(response(&json!({
2767 "jsonrpc":"2.0","id":1,"result":{"resultType":"complete"}
2768 })))
2769 }
2770 }));
2771
2772 let mut call = request(
2773 "tools/call",
2774 Some("tool"),
2775 &json!({"id":1,"params":{"name":"tool"}}),
2776 "secret",
2777 );
2778 if bearer {
2779 call.headers_mut().remove(crate::API_KEY_HEADER);
2781 call.headers_mut().insert(
2782 AUTHORIZATION,
2783 http::HeaderValue::from_static("Bearer secret"),
2784 );
2785 }
2786 consume(service.oneshot(call).await.unwrap()).await;
2787
2788 assert_eq!(
2789 seen.load(std::sync::atomic::Ordering::Relaxed),
2790 expected,
2791 "forwarding={forward} bearer={bearer}"
2792 );
2793 }
2794 }
2795 }
2796
2797 #[tokio::test]
2798 async fn generated_responses_pin_their_content_type_and_stay_out_of_shared_caches() {
2799 let tenants = Arc::new(InMemoryTenantStore::new());
2800 tenants.insert_unchecked("key-a", Tenant::new("a", "cus_a"));
2801 let service = ServiceBuilder::new()
2802 .layer(MeterLayer::new(EdgeConfig::new(tenants)))
2803 .service(service_fn(|_request: Request<Full<Bytes>>| async {
2804 Ok::<_, Infallible>(response(&json!({
2805 "jsonrpc":"2.0","id":1,
2806 "result":{"resultType":"complete","tools":[],"ttlMs":60000,"cacheScope":"private"}
2807 })))
2808 }));
2809
2810 let mut malformed = request("tools/list", None, &json!({"id":1,"params":{}}), "key-a");
2812 malformed.headers_mut().insert(
2813 PROTOCOL_VERSION_HEADER,
2814 http::HeaderValue::from_static("<not-a-date>"),
2815 );
2816 let rejected = service.clone().oneshot(malformed).await.unwrap();
2817 assert_eq!(rejected.status(), StatusCode::BAD_REQUEST);
2818 assert_eq!(rejected.headers()[X_CONTENT_TYPE_OPTIONS], "nosniff");
2819 assert_eq!(rejected.headers()[CACHE_CONTROL], "no-store");
2820
2821 consume(
2822 service
2823 .clone()
2824 .oneshot(request(
2825 "tools/list",
2826 None,
2827 &json!({"id":1,"params":{}}),
2828 "key-a",
2829 ))
2830 .await
2831 .unwrap(),
2832 )
2833 .await;
2834 let hit = service
2835 .oneshot(request(
2836 "tools/list",
2837 None,
2838 &json!({"id":2,"params":{}}),
2839 "key-a",
2840 ))
2841 .await
2842 .unwrap();
2843 assert_eq!(hit.headers()["x-mcp-usage-cache"], "hit");
2844 assert_eq!(hit.headers()[X_CONTENT_TYPE_OPTIONS], "nosniff");
2845 assert_eq!(hit.headers()[CACHE_CONTROL], "private");
2846 consume(hit).await;
2847 }
2848
2849 #[tokio::test]
2850 async fn sustained_guessing_is_refused_without_affecting_valid_keys() {
2851 let tenants = Arc::new(InMemoryTenantStore::new());
2852 tenants.insert_unchecked("secret", Tenant::new("acme", "cus_acme"));
2853 let config = EdgeConfig::new(tenants)
2854 .with_auth_failure_limit(3, std::time::Duration::from_secs(60))
2855 .unwrap();
2856 let metrics = config.metrics();
2857 let service = ServiceBuilder::new()
2858 .layer(MeterLayer::new(config))
2859 .service(service_fn(|_request: Request<Full<Bytes>>| async {
2860 Ok::<_, Infallible>(response(&json!({
2861 "jsonrpc":"2.0","id":1,"result":{"resultType":"complete"}
2862 })))
2863 }));
2864
2865 let guess = |key: &'static str| {
2866 let service = service.clone();
2867 async move {
2868 service
2869 .oneshot(request(
2870 "tools/list",
2871 None,
2872 &json!({"id":1,"params":{}}),
2873 key,
2874 ))
2875 .await
2876 .unwrap()
2877 .status()
2878 }
2879 };
2880
2881 for _ in 0..3 {
2882 assert_eq!(guess("wrong").await, StatusCode::UNAUTHORIZED);
2883 }
2884 assert_eq!(
2885 guess("wrong").await,
2886 StatusCode::TOO_MANY_REQUESTS,
2887 "sustained guessing must stop being answered"
2888 );
2889
2890 let allowed = service
2892 .clone()
2893 .oneshot(request(
2894 "tools/list",
2895 None,
2896 &json!({"id":9,"params":{}}),
2897 "secret",
2898 ))
2899 .await
2900 .unwrap();
2901 assert_eq!(
2902 allowed.status(),
2903 StatusCode::OK,
2904 "throttling guessers must not lock out legitimate callers"
2905 );
2906 consume(allowed).await;
2907
2908 let snapshot = metrics.snapshot();
2909 assert_eq!(snapshot.unauthenticated, 3);
2910 assert_eq!(snapshot.throttled, 1);
2911 }
2912
2913 #[tokio::test]
2914 async fn credential_failures_are_counted_apart_from_header_failures() {
2915 let tenants = Arc::new(InMemoryTenantStore::new());
2916 tenants.insert_unchecked("secret", Tenant::new("acme", "cus_acme"));
2917 let config = EdgeConfig::new(tenants);
2918 let metrics = config.metrics();
2919 let service = ServiceBuilder::new()
2920 .layer(MeterLayer::new(config))
2921 .service(service_fn(|_request: Request<Full<Bytes>>| async {
2922 Ok::<_, Infallible>(response(&json!({"result": {}})))
2923 }));
2924
2925 let unknown_key = service
2926 .clone()
2927 .oneshot(request(
2928 "tools/list",
2929 None,
2930 &json!({"id":1,"params":{}}),
2931 "wrong",
2932 ))
2933 .await
2934 .unwrap();
2935 assert_eq!(unknown_key.status(), StatusCode::UNAUTHORIZED);
2936
2937 let mut malformed = request("tools/list", None, &json!({"id":1,"params":{}}), "secret");
2942 malformed.headers_mut().insert(
2943 PROTOCOL_VERSION_HEADER,
2944 http::HeaderValue::from_static("not-a-date"),
2945 );
2946 assert_eq!(
2947 service.oneshot(malformed).await.unwrap().status(),
2948 StatusCode::BAD_REQUEST
2949 );
2950
2951 let snapshot = metrics.snapshot();
2952 assert_eq!(snapshot.unauthenticated, 1);
2953 assert_eq!(snapshot.rejected, 1);
2954 }
2955
2956 #[test]
2957 fn the_bearer_scheme_is_matched_case_insensitively() {
2958 for header in [
2961 "Bearer secret",
2962 "bearer secret",
2963 "BEARER secret",
2964 "BeArEr secret",
2965 "Bearer secret",
2967 ] {
2968 assert_eq!(bearer_token(header), Some("secret"), "{header}");
2969 }
2970
2971 for header in ["Basic secret", "Bearersecret", "secret", "", " secret"] {
2973 assert_ne!(bearer_token(header), Some("secret"), "{header}");
2974 }
2975 }
2976
2977 #[tokio::test]
2978 async fn a_lowercase_bearer_credential_authenticates() {
2979 let tenants = Arc::new(InMemoryTenantStore::new());
2980 tenants.insert_unchecked("secret", Tenant::new("acme", "cus_acme"));
2981 let service = ServiceBuilder::new()
2982 .layer(MeterLayer::new(EdgeConfig::new(tenants)))
2983 .service(service_fn(|_request: Request<Full<Bytes>>| async {
2984 Ok::<_, Infallible>(response(&json!({
2985 "jsonrpc":"2.0","id":1,"result":{"resultType":"complete"}
2986 })))
2987 }));
2988
2989 let mut call = request("tools/list", None, &json!({"id":1,"params":{}}), "secret");
2990 call.headers_mut().remove(crate::API_KEY_HEADER);
2991 call.headers_mut().insert(
2992 AUTHORIZATION,
2993 http::HeaderValue::from_static("bearer secret"),
2994 );
2995
2996 assert_eq!(
2997 service.oneshot(call).await.unwrap().status(),
2998 StatusCode::OK
2999 );
3000 }
3001
3002 #[test]
3003 fn zero_authentication_failure_window_is_rejected() {
3004 let tenants = Arc::new(InMemoryTenantStore::new());
3005 assert_eq!(
3006 EdgeConfig::new(tenants)
3007 .with_auth_failure_limit(3, Duration::ZERO)
3008 .unwrap_err(),
3009 EdgeConfigError::InvalidAuthFailureWindow
3010 );
3011 }
3012
3013 #[test]
3014 fn extracts_terminal_response_from_sse_without_confusing_notifications() {
3015 let stream = b"data: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\"}\n\n\
3016 : keepalive\n\n\
3017 data: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"resultType\":\"complete\"}}\n\n";
3018 let terminal = terminal_response("text/event-stream", stream).unwrap();
3019 assert_eq!(terminal["id"], 1);
3020 }
3021
3022 #[test]
3023 fn extracts_terminal_response_from_sse_with_mixed_line_endings() {
3024 let stream = b"data: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\"}\r\r\
3025 : keepalive\r\n\r\n\
3026 data: {\"jsonrpc\":\"2.0\",\"id\":7,\r\
3027 data: \"result\":{\"resultType\":\"complete\"}}\n\n";
3028 let terminal = terminal_response("text/event-stream", stream).unwrap();
3029 assert_eq!(terminal["id"], 7);
3030 }
3031
3032 #[test]
3033 fn well_formed_json_that_is_not_a_response_is_not_terminal() {
3034 for body in [
3040 b"0".as_slice(),
3041 b"null".as_slice(),
3042 b"\"a string\"".as_slice(),
3043 b"[]".as_slice(),
3044 b"{}".as_slice(),
3045 br#"{"jsonrpc":"2.0","id":1}"#.as_slice(),
3046 ] {
3047 assert_eq!(
3048 terminal_response("application/json", body),
3049 Err(UnaccountedReason::Unparseable),
3050 "{} is not a JSON-RPC response",
3051 String::from_utf8_lossy(body)
3052 );
3053 }
3054
3055 assert!(
3057 terminal_response(
3058 "application/json",
3059 br#"{"jsonrpc":"2.0","id":1,"result":{"resultType":"complete"}}"#,
3060 )
3061 .is_ok()
3062 );
3063 assert!(
3064 terminal_response(
3065 "application/json",
3066 br#"{"jsonrpc":"2.0","id":1,"error":{"code":-32020}}"#,
3067 )
3068 .is_ok()
3069 );
3070 }
3071
3072 #[tokio::test]
3073 async fn an_origin_that_declares_no_media_type_bills_nothing_and_says_so() {
3074 let config = EdgeConfig::new(task_tenants());
3080 let metrics = config.metrics();
3081 let service = ServiceBuilder::new()
3082 .layer(MeterLayer::new(config))
3083 .service(service_fn(|_request: Request<Full<Bytes>>| async move {
3084 Ok::<_, Infallible>(
3086 Response::builder()
3087 .body(Full::new(Bytes::from(
3088 json!({
3089 "jsonrpc": "2.0",
3090 "id": 1,
3091 "result": {"resultType": "complete", "content": []}
3092 })
3093 .to_string(),
3094 )))
3095 .unwrap(),
3096 )
3097 }));
3098
3099 consume(
3100 service
3101 .oneshot(request(
3102 "tools/call",
3103 Some("long_job"),
3104 &json!({"id": 1, "params": {"name": "long_job"}}),
3105 "secret",
3106 ))
3107 .await
3108 .unwrap(),
3109 )
3110 .await;
3111
3112 let snapshot = metrics.snapshot();
3113 assert_eq!(snapshot.billed, 0, "an undeclared body is never charged");
3114 assert_eq!(snapshot.unrecognized_responses, 1);
3115 assert_eq!(
3116 snapshot.unrecognized_by_reason[UnaccountedReason::MediaType.index()],
3117 1,
3118 "the cause must be attributable to the media type, not to a \
3119 capture overflow or an unparseable body"
3120 );
3121 assert_eq!(
3122 snapshot.unrecognized_by_reason[UnaccountedReason::Unparseable.index()],
3123 0
3124 );
3125 assert_eq!(
3126 snapshot.unrecognized_by_reason[UnaccountedReason::Oversized.index()],
3127 0
3128 );
3129 }
3130
3131 #[test]
3132 fn content_type_matching_requires_an_exact_media_type() {
3133 assert!(has_media_type(
3134 "Application/JSON; charset=utf-8",
3135 "application/json"
3136 ));
3137 assert!(!has_media_type("application/jsonp", "application/json"));
3138
3139 assert_eq!(
3144 terminal_response("application/jsonp", b"{}"),
3145 Err(UnaccountedReason::MediaType)
3146 );
3147 assert!(
3148 terminal_response(
3149 "application/json",
3150 br#"{"jsonrpc":"2.0","id":1,"result":{"resultType":"complete"}}"#
3151 )
3152 .is_ok()
3153 );
3154 }
3155
3156 #[tokio::test]
3157 async fn oversized_request_bodies_are_rejected_before_the_origin() {
3158 let tenants = Arc::new(InMemoryTenantStore::new());
3159 tenants.insert_unchecked("secret", Tenant::new("acme", "cus_acme"));
3160 let calls = Arc::new(std::sync::atomic::AtomicU64::new(0));
3161 let inner_calls = calls.clone();
3162 let service = ServiceBuilder::new()
3163 .layer(MeterLayer::new(
3164 EdgeConfig::new(tenants).with_max_request_body(16),
3165 ))
3166 .service(service_fn(move |_request: Request<Full<Bytes>>| {
3167 inner_calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3168 async { Ok::<_, Infallible>(response(&json!({"result": {}}))) }
3169 }));
3170
3171 let response = service
3172 .oneshot(request(
3173 "tools/call",
3174 Some("tool"),
3175 &json!({"id":1,"params":{"name":"tool","payload":"too large"}}),
3176 "secret",
3177 ))
3178 .await
3179 .unwrap();
3180 assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
3181 assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 0);
3182 }
3183
3184 #[tokio::test]
3185 async fn ambiguous_or_duplicate_security_headers_are_rejected() {
3186 let tenants = Arc::new(InMemoryTenantStore::new());
3187 tenants.insert_unchecked("secret", Tenant::new("acme", "cus_acme"));
3188 let service = ServiceBuilder::new()
3189 .layer(MeterLayer::new(EdgeConfig::new(tenants)))
3190 .service(service_fn(|_request: Request<Full<Bytes>>| async {
3191 Ok::<_, Infallible>(response(&json!({"result": {}})))
3192 }));
3193
3194 let mut ambiguous = request("tools/list", None, &json!({"id":1,"params":{}}), "secret");
3195 ambiguous.headers_mut().insert(
3196 AUTHORIZATION,
3197 http::HeaderValue::from_static("Bearer secret"),
3198 );
3199 assert_eq!(
3200 service.clone().oneshot(ambiguous).await.unwrap().status(),
3201 StatusCode::UNAUTHORIZED
3202 );
3203
3204 let mut duplicate = request("tools/list", None, &json!({"id":1,"params":{}}), "secret");
3205 duplicate
3206 .headers_mut()
3207 .append(METHOD_HEADER, http::HeaderValue::from_static("tools/list"));
3208 assert_eq!(
3209 service.oneshot(duplicate).await.unwrap().status(),
3210 StatusCode::BAD_REQUEST
3211 );
3212 }
3213
3214 #[tokio::test]
3215 async fn a_header_body_mismatch_cannot_use_the_cache() {
3216 let tenants = Arc::new(InMemoryTenantStore::new());
3217 tenants.insert_unchecked("secret", Tenant::new("acme", "cus_acme"));
3218 let calls = Arc::new(std::sync::atomic::AtomicU64::new(0));
3219 let inner_calls = calls.clone();
3220 let service = ServiceBuilder::new()
3221 .layer(MeterLayer::new(EdgeConfig::new(tenants)))
3222 .service(service_fn(move |_request: Request<Full<Bytes>>| {
3223 inner_calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3224 async {
3225 Ok::<_, Infallible>(response(&json!({
3226 "id":1,
3227 "result":{
3228 "resultType":"complete",
3229 "tools":[],
3230 "ttlMs":60000,
3231 "cacheScope":"private"
3232 }
3233 })))
3234 }
3235 }));
3236
3237 consume(
3238 service
3239 .clone()
3240 .oneshot(request(
3241 "tools/list",
3242 None,
3243 &json!({"id":1,"params":{}}),
3244 "secret",
3245 ))
3246 .await
3247 .unwrap(),
3248 )
3249 .await;
3250 consume(
3251 service
3252 .oneshot(request(
3253 "tools/list",
3254 None,
3255 &json!({"id":2,"method":"resources/list","params":{}}),
3256 "secret",
3257 ))
3258 .await
3259 .unwrap(),
3260 )
3261 .await;
3262 assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 2);
3263 }
3264}