mcp_usage_core/lib.rs
1//! Billing-attribution semantics for MCP 2026-07-28.
2//!
3//! This crate answers one question: **given an MCP request and the response it
4//! got, what is owed?** It performs no I/O, spawns nothing, knows nothing about
5//! HTTP, and never touches a billing provider. Keeping attribution separate makes
6//! it possible to test the billing rules without a network.
7//!
8//! # Why request counts are insufficient
9//!
10//! Before 2026-07-28, metering an MCP server was counting requests. That revision
11//! made counting requests wrong in three separate ways:
12//!
13//! - **Multi Round-Trip Requests.** A server that needs user input mid-call now
14//! returns an interim result and the client *retries the whole call*. One
15//! logical `tools/call` becomes N HTTP requests, each carrying
16//! `Mcp-Method: tools/call`. Counting requests bills N for one unit of work.
17//! - **Tasks.** A long job returns a handle and the client polls `tasks/get`
18//! until it finishes. A ten-minute job on a two-second poll interval is ~300
19//! requests for one unit of work.
20//! - **Caching.** `tools/list` and friends carry `ttlMs` and `cacheScope` so they
21//! can stop reaching the origin. Billing discovery traffic charges for the act
22//! of connecting.
23//!
24//! The fix is to stop counting requests. See [`charge`] for the rule and the
25//! reasoning behind every claim here.
26//!
27//! # Shape of a call site
28//!
29//! ```
30//! use mcp_usage_core::{Call, Charge, Method, PriceBook, name, peek, version};
31//!
32//! // 1. Refuse to price on headers a legacy origin will never validate.
33//! let trust = version::assess(Some("2026-07-28"));
34//! assert!(trust.is_trusted());
35//!
36//! // 2. Classify from headers alone. No body, no allocation on the common path.
37//! let method = Method::parse("tools/call");
38//! let tool = name::decode("get_weather").expect("valid header value");
39//!
40//! // 3. Decide once the response is known.
41//! let body = serde_json::json!({ "result": { "resultType": "complete" } });
42//! let charge = mcp_usage_core::decide(
43//! &Call::new(method, Some(tool.into_owned())),
44//! &peek::response(&body),
45//! &PriceBook::flat(1),
46//! );
47//!
48//! assert!(matches!(charge, Charge::Billable(_)));
49//! assert_eq!(charge.units(), 1);
50//! ```
51//!
52//! # Where the state lives
53//!
54//! Nowhere in here. Durable tasks require the caller to retain two small pieces
55//! of state: pre-priced [`TaskAttribution`] keyed by `taskId` and the task IDs
56//! already billed. Resolving the price when the task is created avoids
57//! persisting a tool name, prompt name, resource URI, or extension method text.
58//! This crate accepts the former in [`decide_with_task_attribution`] and surfaces
59//! the latter as [`Billable::idempotency_key`]. Enforcement belongs to the
60//! caller, which is the only layer that knows whether it runs beside the origin
61//! or as a shared proxy.
62
63#![forbid(unsafe_code)]
64#![warn(missing_docs, clippy::pedantic)]
65#![allow(clippy::module_name_repetitions)]
66
67pub mod charge;
68pub mod legacy;
69pub mod limits;
70pub mod method;
71pub mod name;
72pub mod peek;
73pub mod price;
74pub mod version;
75
76pub use charge::{
77 Billable, Call, Charge, FreeReason, TaskAttribution, TaskOriginKind, decide,
78 decide_with_task_attribution, decide_with_task_origin,
79};
80pub use legacy::{LegacyClassificationError, classify_body};
81pub use limits::{LimitDecision, LimitReason, Limits, Usage, assess_limits};
82pub use method::Method;
83pub use peek::{RequestPeek, ResponsePeek, ResultType, TaskPeek, TaskStatus};
84pub use price::PriceBook;
85pub use version::{HeaderTrust, ProtocolVersion, TrustFailure, validate_header};