Skip to main content

mcp_usage_core/
charge.rs

1//! The billing decision: what a request/response pair is worth.
2//!
3//! # The rule
4//!
5//! **Charge on terminal delivery, never on request.**
6//!
7//! Every consequence a meter needs falls out of that one sentence, with no chain
8//! correlation, no session, and no state:
9//!
10//! | Shape | Why it lands right |
11//! |---|---|
12//! | An MRTR chain of any length | Only its final round trip is `resultType: "complete"`, so it bills once |
13//! | An abandoned chain | Never reaches `complete`, so it bills nothing |
14//! | A task polled 300 times | Only the poll reporting `status: "completed"` delivered anything |
15//! | A task that fails or is cancelled | Terminal, but delivered nothing, so nothing is owed |
16//! | Discovery traffic | Never billable; it is the cost of connecting |
17//! | A protocol error | The server failed; charging for that is indefensible |
18//!
19//! This replaced an earlier design that tried to collapse MRTR chains by
20//! correlating their round trips. That design cannot be built by an intermediary:
21//! `requestState` is "an opaque string meaningful only to the server", which
22//! servers MUST integrity-protect with HMAC or AEAD, so the only correlator on
23//! the wire is encrypted under a key the meter does not hold. Charging on
24//! delivery needs no correlator, which is why it is both correct and cheaper.
25//!
26//! # Continuation state
27//!
28//! It does not consider whether the *request* was a continuation. Under the rule
29//! above that fact is irrelevant to billing, and requiring it would force a body
30//! peek on every `tools/call`. [`crate::peek::request`] still exposes it for the
31//! cache, because MRTR-derived results MUST NOT be cached.
32//!
33//! # Replay
34//!
35//! Two double-billing vectors exist, and they are treated differently on purpose.
36//!
37//! A **terminal task can be polled forever.** Terminal state "does not change", so
38//! every later `tasks/get` reports `completed` again. That is one unit of work
39//! reported many times, so [`Billable::idempotency_key`] carries the `taskId` and
40//! the caller MUST suppress repeats.
41//!
42//! A **replayed MRTR continuation** may produce a second `complete`. The spec
43//! bounds the replay window but explicitly does not guarantee single use. We bill
44//! it because the origin executed the tool a second time. Two completed executions
45//! produce two charges.
46
47use crate::peek::{ResponsePeek, ResultType};
48use crate::price::PriceBook;
49use crate::{Method, TaskStatus};
50
51/// The call being priced, as known from its (trusted) headers.
52///
53/// `name` must already be decoded from the `Mcp-Name` sentinel form.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct Call {
56    /// MCP method whose delivered work is being priced.
57    pub method: Method,
58    /// Decoded `Mcp-Name`, when the method carries one.
59    pub name: Option<String>,
60}
61
62impl Call {
63    /// Construct a classified call from trusted, decoded headers.
64    #[must_use]
65    pub fn new(method: Method, name: Option<String>) -> Self {
66        Self { method, name }
67    }
68}
69
70/// Non-identifying method category retained for a durable task.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum TaskOriginKind {
73    /// A task created by `tools/call`.
74    ToolsCall,
75    /// A task created by `resources/read`.
76    ResourcesRead,
77    /// A task created by `prompts/get`.
78    PromptsGet,
79    /// A task created by an extension or unexpected method.
80    Other,
81}
82
83impl TaskOriginKind {
84    /// Reduce a method to a fixed category that cannot retain extension text.
85    #[must_use]
86    pub const fn from_method(method: &Method) -> Self {
87        match method {
88            Method::ToolsCall => Self::ToolsCall,
89            Method::ResourcesRead => Self::ResourcesRead,
90            Method::PromptsGet => Self::PromptsGet,
91            _ => Self::Other,
92        }
93    }
94
95    fn method(self) -> Method {
96        match self {
97            Self::ToolsCall => Method::ToolsCall,
98            Self::ResourcesRead => Method::ResourcesRead,
99            Self::PromptsGet => Method::PromptsGet,
100            Self::Other => Method::Other("[redacted-task-origin]".to_owned()),
101        }
102    }
103}
104
105/// Pre-priced durable-task attribution without the original name or URI.
106///
107/// Construct this when a task is created, then persist it under the task ID.
108/// The resolved units preserve named pricing while avoiding storage of a tool
109/// name, prompt name, resource URI, or extension method string.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub struct TaskAttribution {
112    origin_kind: TaskOriginKind,
113    units: u64,
114}
115
116impl TaskAttribution {
117    /// Construct an attribution from an already resolved method category and price.
118    #[must_use]
119    pub const fn new(origin_kind: TaskOriginKind, units: u64) -> Self {
120        Self { origin_kind, units }
121    }
122
123    /// Resolve a call against the current price book and discard identifying text.
124    #[must_use]
125    pub fn from_call(call: &Call, prices: &PriceBook) -> Self {
126        Self {
127            origin_kind: TaskOriginKind::from_method(&call.method),
128            units: prices.units_for(&call.method, call.name.as_deref()),
129        }
130    }
131
132    /// Fixed, non-identifying category of the call that created the task.
133    #[must_use]
134    pub const fn origin_kind(self) -> TaskOriginKind {
135        self.origin_kind
136    }
137
138    /// Units resolved when the task was created.
139    #[must_use]
140    pub const fn units(self) -> u64 {
141        self.units
142    }
143}
144
145/// A charge to record against a tenant.
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct Billable {
148    /// Method that originally commissioned the delivered work.
149    pub method: Method,
150    /// Decoded tool, prompt, or resource name when available.
151    pub name: Option<String>,
152    /// Integer units to record with the billing backend.
153    pub units: u64,
154    /// When present, the caller MUST NOT record this charge more than once for
155    /// this key. Carried by task completions, whose terminal state can be
156    /// re-observed by any number of later polls.
157    pub idempotency_key: Option<String>,
158}
159
160/// Why a call was not billed. Kept specific so an operator answering "why is my
161/// bill lower than my request count" gets a real answer from the metrics.
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
163pub enum FreeReason {
164    /// `tools/list` and friends: the cost of connecting, not of work.
165    Discovery,
166    /// An interim `input_required` result. Nothing delivered yet.
167    InterimInputRequired,
168    /// A `CreateTaskResult`: work accepted, not performed.
169    TaskCreated,
170    /// A poll of a task still `working` or awaiting input.
171    TaskInProgress,
172    /// A task that reached a terminal state without delivering: failed, cancelled.
173    TaskNotDelivered,
174    /// `tasks/update` or `tasks/cancel`: driving a task, not receiving its output.
175    TaskDrive,
176    /// A completed task could not be joined to the call that created it.
177    MissingTaskAttribution,
178    /// A completed task omitted its required durable identifier, so repeat
179    /// polls could not be made idempotent.
180    MissingTaskId,
181    /// A `subscriptions/listen` stream. Not a discrete call; see the findings doc.
182    Subscription,
183    /// A JSON-RPC error. The server did not deliver.
184    ProtocolError,
185    /// A result shape this build does not recognize. Fails toward not charging.
186    UnrecognizedResult,
187}
188
189/// The verdict.
190#[derive(Debug, Clone, PartialEq, Eq)]
191pub enum Charge {
192    /// Delivered work that should be recorded.
193    Billable(Billable),
194    /// An exchange for which no usage is owed.
195    Free(FreeReason),
196}
197
198impl Charge {
199    /// Units owed, or zero when free.
200    #[must_use]
201    pub fn units(&self) -> u64 {
202        match self {
203            Self::Billable(b) => b.units,
204            Self::Free(_) => 0,
205        }
206    }
207
208    /// Whether the verdict records delivered work (including zero-priced work).
209    #[must_use]
210    pub const fn is_billable(&self) -> bool {
211        matches!(self, Self::Billable(_))
212    }
213
214    /// The idempotency key, when this charge carries one.
215    #[must_use]
216    pub fn idempotency_key(&self) -> Option<&str> {
217        match self {
218            Self::Billable(b) => b.idempotency_key.as_deref(),
219            Self::Free(_) => None,
220        }
221    }
222}
223
224/// Decide what a completed request/response exchange is worth.
225///
226/// Ordering matters: errors are checked before anything else so a failure is
227/// never billed regardless of which method produced it, and task handling
228/// precedes the generic `resultType` arm because a task poll legitimately carries
229/// `resultType: "complete"` while delivering nothing but a progress report.
230#[must_use]
231pub fn decide(call: &Call, response: &ResponsePeek, prices: &PriceBook) -> Charge {
232    decide_with_task_origin(call, response, prices, None)
233}
234
235enum TaskPricing<'a> {
236    Call(&'a Call),
237    Attribution(&'a TaskAttribution),
238}
239
240/// Decide an exchange while supplying the call that created a polled task.
241///
242/// A `tasks/get` request carries no `Mcp-Name`, so its terminal response cannot
243/// be priced correctly without the attribution captured from the original
244/// `tools/call`. Callers must persist that association when they observe a
245/// `resultType: "task"` response and pass it here on subsequent polls.
246#[must_use]
247pub fn decide_with_task_origin(
248    call: &Call,
249    response: &ResponsePeek,
250    prices: &PriceBook,
251    task_origin: Option<&Call>,
252) -> Charge {
253    decide_with_task_pricing(call, response, prices, task_origin.map(TaskPricing::Call))
254}
255
256/// Decide an exchange using pre-priced, non-identifying task attribution.
257///
258/// This is the privacy-preserving form for durable storage. The task's units
259/// are resolved when it is created, so a later terminal poll does not need the
260/// original name, URI, or extension method string.
261#[must_use]
262pub fn decide_with_task_attribution(
263    call: &Call,
264    response: &ResponsePeek,
265    prices: &PriceBook,
266    task_attribution: Option<&TaskAttribution>,
267) -> Charge {
268    decide_with_task_pricing(
269        call,
270        response,
271        prices,
272        task_attribution.map(TaskPricing::Attribution),
273    )
274}
275
276fn decide_with_task_pricing(
277    call: &Call,
278    response: &ResponsePeek,
279    prices: &PriceBook,
280    task_pricing: Option<TaskPricing<'_>>,
281) -> Charge {
282    if response.is_error {
283        return Charge::Free(FreeReason::ProtocolError);
284    }
285
286    if call.method.is_discovery() {
287        return Charge::Free(FreeReason::Discovery);
288    }
289
290    if matches!(call.method, Method::SubscriptionsListen) {
291        return Charge::Free(FreeReason::Subscription);
292    }
293
294    // A task handle accepts work but does not deliver it, regardless of the
295    // seed status fields carried beside the discriminant.
296    if matches!(response.result_type, ResultType::Task) {
297        return Charge::Free(FreeReason::TaskCreated);
298    }
299
300    // Task lifecycle. A poll's own resultType says the poll succeeded, which says
301    // nothing about the job, so the task status decides. The original call, not
302    // `tasks/get`, owns the price and attribution.
303    if matches!(call.method, Method::TasksGet)
304        && let Some(task) = &response.task
305    {
306        return match &task.status {
307            s if s.delivered() => {
308                let Some(task_id) = task.task_id.clone() else {
309                    return Charge::Free(FreeReason::MissingTaskId);
310                };
311                let Some(origin) = task_pricing else {
312                    return Charge::Free(FreeReason::MissingTaskAttribution);
313                };
314                let (method, name, units) = match origin {
315                    TaskPricing::Call(origin) => (
316                        origin.method.clone(),
317                        origin.name.clone(),
318                        prices.units_for(&origin.method, origin.name.as_deref()),
319                    ),
320                    TaskPricing::Attribution(attribution) => {
321                        (attribution.origin_kind.method(), None, attribution.units)
322                    }
323                };
324                Charge::Billable(Billable {
325                    method,
326                    name,
327                    units,
328                    // A terminal task reports `completed` to every later poll.
329                    idempotency_key: Some(task_id),
330                })
331            }
332            s if s.is_terminal() => Charge::Free(FreeReason::TaskNotDelivered),
333            TaskStatus::Working | TaskStatus::InputRequired => {
334                Charge::Free(FreeReason::TaskInProgress)
335            }
336            _ => Charge::Free(FreeReason::TaskInProgress),
337        };
338    }
339
340    if call.method.is_task_drive() {
341        return Charge::Free(FreeReason::TaskDrive);
342    }
343
344    match &response.result_type {
345        ResultType::Complete => Charge::Billable(Billable {
346            method: call.method.clone(),
347            name: call.name.clone(),
348            units: prices.units_for(&call.method, call.name.as_deref()),
349            idempotency_key: None,
350        }),
351        ResultType::InputRequired => Charge::Free(FreeReason::InterimInputRequired),
352        ResultType::Task => Charge::Free(FreeReason::TaskCreated),
353        ResultType::Unknown(_) | ResultType::Absent => Charge::Free(FreeReason::UnrecognizedResult),
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360    use crate::peek::{TaskPeek, response as peek_response};
361    use serde_json::json;
362
363    fn call(method: Method, name: &str) -> Call {
364        Call::new(method, Some(name.to_owned()))
365    }
366
367    fn book() -> PriceBook {
368        PriceBook::flat(1)
369    }
370
371    fn complete() -> ResponsePeek {
372        peek_response(&json!({ "result": { "resultType": "complete", "content": [] } }))
373    }
374
375    fn input_required() -> ResponsePeek {
376        peek_response(&json!({
377            "result": {
378                "resultType": "input_required",
379                "inputRequests": { "k": { "method": "elicitation/create" } },
380                "requestState": "AEAD-protected blob"
381            }
382        }))
383    }
384
385    fn task_poll(status: &str, id: &str) -> ResponsePeek {
386        peek_response(&json!({
387            "result": { "resultType": "complete", "taskId": id, "status": status }
388        }))
389    }
390
391    #[test]
392    fn a_delivered_tool_call_bills_once() {
393        let charge = decide(
394            &call(Method::ToolsCall, "get_weather"),
395            &complete(),
396            &book(),
397        );
398        assert!(charge.is_billable());
399        assert_eq!(charge.units(), 1);
400        assert_eq!(charge.idempotency_key(), None);
401    }
402
403    #[test]
404    fn an_mrtr_chain_of_any_length_bills_exactly_once() {
405        // The property that motivated the whole crate. Rounds 1..n-1 are interim;
406        // only the last one delivers.
407        for rounds in 1..=10 {
408            let c = call(Method::ToolsCall, "get_weather");
409            let mut total = 0;
410            for round in 1..=rounds {
411                let response = if round == rounds {
412                    complete()
413                } else {
414                    input_required()
415                };
416                total += decide(&c, &response, &book()).units();
417            }
418            assert_eq!(total, 1, "a {rounds}-round chain billed {total} units");
419        }
420    }
421
422    #[test]
423    fn an_abandoned_chain_bills_nothing() {
424        let c = call(Method::ToolsCall, "get_weather");
425        let total: u64 = (0..5)
426            .map(|_| decide(&c, &input_required(), &book()).units())
427            .sum();
428        assert_eq!(total, 0);
429    }
430
431    #[test]
432    fn mrtr_applies_to_resources_read_and_prompts_get_too() {
433        for method in [Method::ResourcesRead, Method::PromptsGet] {
434            let c = call(method.clone(), "x");
435            assert_eq!(decide(&c, &input_required(), &book()).units(), 0);
436            assert_eq!(decide(&c, &complete(), &book()).units(), 1);
437        }
438    }
439
440    #[test]
441    fn a_task_polled_many_times_bills_once() {
442        let poll = Call::new(Method::TasksGet, None);
443        let origin = call(Method::ToolsCall, "long_job");
444        let mut total = 0;
445        let mut billed_keys: Vec<String> = Vec::new();
446
447        // 300 progress polls, then the completion.
448        for _ in 0..300 {
449            total += decide_with_task_origin(
450                &poll,
451                &task_poll("working", "tsk_1"),
452                &book(),
453                Some(&origin),
454            )
455            .units();
456        }
457        let final_charge = decide_with_task_origin(
458            &poll,
459            &task_poll("completed", "tsk_1"),
460            &book(),
461            Some(&origin),
462        );
463        if let Some(key) = final_charge.idempotency_key() {
464            billed_keys.push(key.to_owned());
465        }
466        total += final_charge.units();
467
468        assert_eq!(total, 1, "300 polls plus a completion billed {total} units");
469        assert_eq!(billed_keys, vec!["tsk_1".to_owned()]);
470    }
471
472    #[test]
473    fn a_completed_task_carries_its_id_so_repeat_polls_can_be_suppressed() {
474        // Terminal state does not change, so nothing stops a client polling a
475        // finished task forever. Core flags it; the caller enforces once-only.
476        let poll = Call::new(Method::TasksGet, None);
477        let origin = call(Method::ToolsCall, "long_job");
478        let first = decide_with_task_origin(
479            &poll,
480            &task_poll("completed", "tsk_9"),
481            &book(),
482            Some(&origin),
483        );
484        let second = decide_with_task_origin(
485            &poll,
486            &task_poll("completed", "tsk_9"),
487            &book(),
488            Some(&origin),
489        );
490
491        assert!(first.is_billable() && second.is_billable());
492        assert_eq!(first.idempotency_key(), Some("tsk_9"));
493        assert_eq!(first.idempotency_key(), second.idempotency_key());
494    }
495
496    #[test]
497    fn failed_and_cancelled_tasks_are_free() {
498        let c = Call::new(Method::TasksGet, None);
499        assert_eq!(
500            decide(&c, &task_poll("failed", "t"), &book()),
501            Charge::Free(FreeReason::TaskNotDelivered)
502        );
503        assert_eq!(
504            decide(&c, &task_poll("cancelled", "t"), &book()),
505            Charge::Free(FreeReason::TaskNotDelivered)
506        );
507    }
508
509    #[test]
510    fn a_task_awaiting_input_is_free() {
511        let c = Call::new(Method::TasksGet, None);
512        assert_eq!(
513            decide(&c, &task_poll("input_required", "t"), &book()),
514            Charge::Free(FreeReason::TaskInProgress)
515        );
516    }
517
518    #[test]
519    fn creating_a_task_is_not_delivering_it() {
520        let created = peek_response(&json!({
521            "result": { "resultType": "task", "taskId": "tsk_1", "status": "working" }
522        }));
523        assert_eq!(
524            decide(&call(Method::ToolsCall, "t"), &created, &book()),
525            Charge::Free(FreeReason::TaskCreated)
526        );
527    }
528
529    #[test]
530    fn task_drive_calls_are_free() {
531        let ack = peek_response(&json!({ "result": { "resultType": "complete" } }));
532        for method in [Method::TasksUpdate, Method::TasksCancel] {
533            assert_eq!(
534                decide(&Call::new(method, None), &ack, &book()),
535                Charge::Free(FreeReason::TaskDrive)
536            );
537        }
538    }
539
540    #[test]
541    fn discovery_is_never_billed() {
542        for method in [
543            Method::ToolsList,
544            Method::PromptsList,
545            Method::ResourcesList,
546            Method::ResourcesTemplatesList,
547            Method::ServerDiscover,
548        ] {
549            assert_eq!(
550                decide(&Call::new(method, None), &complete(), &book()),
551                Charge::Free(FreeReason::Discovery)
552            );
553        }
554    }
555
556    #[test]
557    fn subscription_streams_are_free() {
558        assert_eq!(
559            decide(
560                &Call::new(Method::SubscriptionsListen, None),
561                &complete(),
562                &book()
563            ),
564            Charge::Free(FreeReason::Subscription)
565        );
566    }
567
568    #[test]
569    fn errors_are_never_billed_whatever_the_method() {
570        let err = peek_response(&json!({ "error": { "code": -32020, "message": "mismatch" } }));
571        for method in [Method::ToolsCall, Method::TasksGet, Method::ResourcesRead] {
572            assert_eq!(
573                decide(&call(method, "x"), &err, &book()),
574                Charge::Free(FreeReason::ProtocolError)
575            );
576        }
577    }
578
579    #[test]
580    fn unrecognized_results_fail_toward_not_charging() {
581        let weird = peek_response(&json!({ "result": { "resultType": "some_future_shape" } }));
582        assert_eq!(
583            decide(&call(Method::ToolsCall, "t"), &weird, &book()),
584            Charge::Free(FreeReason::UnrecognizedResult)
585        );
586
587        let empty = peek_response(&json!({ "jsonrpc": "2.0" }));
588        assert_eq!(
589            decide(&call(Method::ToolsCall, "t"), &empty, &book()),
590            Charge::Free(FreeReason::UnrecognizedResult)
591        );
592    }
593
594    #[test]
595    fn price_book_drives_the_units() {
596        let prices = PriceBook::flat(1).with_name("expensive", 250);
597        assert_eq!(
598            decide(&call(Method::ToolsCall, "expensive"), &complete(), &prices).units(),
599            250
600        );
601        assert_eq!(
602            decide(&call(Method::ToolsCall, "cheap"), &complete(), &prices).units(),
603            1
604        );
605    }
606
607    #[test]
608    fn a_zero_priced_tool_still_reports_as_billable_with_zero_units() {
609        // Distinguishing "free by price" from "free by protocol shape" keeps the
610        // metrics honest about why a call cost nothing.
611        let prices = PriceBook::flat(5).with_name("loss_leader", 0);
612        let charge = decide(
613            &call(Method::ToolsCall, "loss_leader"),
614            &complete(),
615            &prices,
616        );
617        assert!(charge.is_billable());
618        assert_eq!(charge.units(), 0);
619    }
620
621    #[test]
622    fn an_unknown_task_status_is_not_billed() {
623        let paused = peek_response(&json!({
624            "result": { "resultType": "complete", "taskId": "t", "status": "paused" }
625        }));
626        assert!(!decide(&Call::new(Method::TasksGet, None), &paused, &book()).is_billable());
627    }
628
629    #[test]
630    fn a_task_without_an_id_fails_toward_not_charging() {
631        let peek = ResponsePeek {
632            result_type: ResultType::Complete,
633            is_error: false,
634            task: Some(TaskPeek {
635                task_id: None,
636                status: TaskStatus::Completed,
637            }),
638        };
639        let charge = decide_with_task_origin(
640            &Call::new(Method::TasksGet, None),
641            &peek,
642            &book(),
643            Some(&call(Method::ToolsCall, "j")),
644        );
645        assert_eq!(charge, Charge::Free(FreeReason::MissingTaskId));
646    }
647
648    #[test]
649    fn a_completed_task_without_origin_fails_toward_not_charging() {
650        let charge = decide(
651            &Call::new(Method::TasksGet, None),
652            &task_poll("completed", "tsk_orphan"),
653            &book(),
654        );
655        assert_eq!(charge, Charge::Free(FreeReason::MissingTaskAttribution));
656    }
657
658    #[test]
659    fn a_completed_task_uses_the_originating_tool_price() {
660        let prices = PriceBook::flat(1).with_name("expensive_job", 250);
661        let charge = decide_with_task_origin(
662            &Call::new(Method::TasksGet, None),
663            &task_poll("completed", "tsk_expensive"),
664            &prices,
665            Some(&call(Method::ToolsCall, "expensive_job")),
666        );
667        assert_eq!(charge.units(), 250);
668        let Charge::Billable(billable) = charge else {
669            panic!("completed task should be billable");
670        };
671        assert_eq!(billable.method, Method::ToolsCall);
672        assert_eq!(billable.name.as_deref(), Some("expensive_job"));
673    }
674
675    #[test]
676    fn prepriced_task_attribution_discards_identifying_names() {
677        let prices = PriceBook::flat(1).with_name("file:///private/customer-record", 250);
678        let origin = Call::new(
679            Method::ResourcesRead,
680            Some("file:///private/customer-record".to_owned()),
681        );
682        let attribution = TaskAttribution::from_call(&origin, &prices);
683
684        assert_eq!(attribution.units(), 250);
685        assert_eq!(attribution.origin_kind(), TaskOriginKind::ResourcesRead);
686        assert!(!format!("{attribution:?}").contains("customer-record"));
687
688        let charge = decide_with_task_attribution(
689            &Call::new(Method::TasksGet, None),
690            &task_poll("completed", "tsk_private"),
691            &prices,
692            Some(&attribution),
693        );
694        let Charge::Billable(billable) = charge else {
695            panic!("completed task should be billable");
696        };
697        assert_eq!(billable.units, 250);
698        assert_eq!(billable.method, Method::ResourcesRead);
699        assert_eq!(billable.name, None);
700    }
701}