Skip to main content

mcp_usage_core/
peek.rs

1//! The bounded body inspections the spec forces on a meter, and nothing more.
2//!
3//! Two facts a meter needs are not mirrored into any header.
4//!
5//! **Is this request a continuation?** A retried MRTR round trip is an ordinary
6//! `tools/call` with an ordinary `Mcp-Method: tools/call` header. What marks it is
7//! `inputResponses` or `requestState` under `params`, both body fields. The spec
8//! uses exactly this test itself when defining what must not be cached:
9//! "requests carrying `inputResponses` or `requestState`". We do not attempt to
10//! read `requestState`; it is "an opaque string meaningful only to the server",
11//! integrity-protected with HMAC or AEAD, and its contents are none of our
12//! business. Its mere presence is the signal.
13//!
14//! **Did this response deliver the work?** `resultType` distinguishes a finished
15//! result from an interim one, and for the tasks extension the task `status`
16//! distinguishes a finished job from a progress report. Both live in the response
17//! body.
18//!
19//! The cost of these peeks is bounded by [`crate::Method::supports_mrtr`]: only
20//! three of the twelve core methods can ever be a continuation, so nine of them
21//! are decided from headers alone.
22
23use serde_json::Value;
24use std::fmt;
25
26/// The `resultType` discriminant on an MCP result.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum ResultType {
29    /// A finished result. The work was delivered.
30    Complete,
31    /// An interim result asking for more input. Nothing was delivered.
32    InputRequired,
33    /// A `CreateTaskResult`: the work was accepted, not performed.
34    Task,
35    /// A discriminant this build does not know. Never billed, so an unknown
36    /// future result shape fails to the customer's advantage rather than ours.
37    Unknown(String),
38    /// No `resultType` present on a result. Only legacy servers do this, and the
39    /// version guard should already have rejected them.
40    Absent,
41}
42
43/// Status of a task under the `io.modelcontextprotocol/tasks` extension.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum TaskStatus {
46    /// Work is still executing.
47    Working,
48    /// Work is paused until the client supplies input.
49    InputRequired,
50    /// Work finished and its result is available.
51    Completed,
52    /// Work terminated with a protocol-level failure.
53    Failed,
54    /// Work was cancelled before delivery.
55    Cancelled,
56    /// A status introduced after this build was published.
57    Unknown(String),
58}
59
60impl TaskStatus {
61    /// Parse a task status while preserving unknown future values.
62    #[must_use]
63    pub fn parse(raw: &str) -> Self {
64        match raw {
65            "working" => Self::Working,
66            "input_required" => Self::InputRequired,
67            "completed" => Self::Completed,
68            "failed" => Self::Failed,
69            "cancelled" => Self::Cancelled,
70            other => Self::Unknown(other.to_owned()),
71        }
72    }
73
74    /// Whether the task has reached a state it will never leave.
75    ///
76    /// `completed`, `failed`, and `cancelled` are terminal per the extension.
77    #[must_use]
78    pub const fn is_terminal(&self) -> bool {
79        matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
80    }
81
82    /// Whether the task delivered the work that was originally asked for.
83    ///
84    /// Only `completed` did. `failed` and `cancelled` are terminal but delivered
85    /// nothing, and billing for them would charge for the server's own failures.
86    #[must_use]
87    pub const fn delivered(&self) -> bool {
88        matches!(self, Self::Completed)
89    }
90}
91
92impl fmt::Display for TaskStatus {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        f.write_str(match self {
95            Self::Working => "working",
96            Self::InputRequired => "input_required",
97            Self::Completed => "completed",
98            Self::Failed => "failed",
99            Self::Cancelled => "cancelled",
100            Self::Unknown(s) => s,
101        })
102    }
103}
104
105/// What a request body reveals that its headers cannot.
106#[derive(Debug, Clone, PartialEq, Eq, Default)]
107pub struct RequestPeek {
108    /// The request carries `inputResponses` or `requestState`, so it resumes an
109    /// earlier round trip rather than starting fresh.
110    pub is_continuation: bool,
111}
112
113/// What a response body reveals.
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct ResponsePeek {
116    /// The `resultType` discriminant, or [`ResultType::Absent`].
117    pub result_type: ResultType,
118    /// The response is a JSON-RPC error rather than a result.
119    pub is_error: bool,
120    /// Task state, when the result carries one.
121    pub task: Option<TaskPeek>,
122}
123
124impl ResponsePeek {
125    /// Read an absent `resultType` as delivered work, for a legacy request.
126    ///
127    /// `resultType` arrived with the interim-result machinery in
128    /// [`crate::version::HEADER_VALIDATION_SINCE`]. A server answering an older client
129    /// has no way to express "this is interim" and no reason to: on those revisions a
130    /// JSON-RPC `result` is the delivered work, full stop. Treating its absence as
131    /// unrecognized - correct for a modern response, where a missing discriminant means
132    /// something unparsed - would make every legacy call free.
133    ///
134    /// Apply this only when the request itself was classified as legacy. A modern
135    /// response missing its discriminant must stay unrecognized: there, absence means
136    /// the meter failed to understand the result, and guessing would bill for work that
137    /// may never have been delivered.
138    ///
139    /// Scoped to the methods that can deliver priced work at all. Without that,
140    /// the promotion reaches every legacy method, and `{"result":{}}` from a
141    /// `ping` or an `initialize` becomes a billable delivery: a connected but
142    /// idle legacy client is then charged for its keepalives forever, while a
143    /// modern client generating identical traffic is charged nothing.
144    #[must_use]
145    pub fn with_legacy_delivery(mut self, method: &crate::Method) -> Self {
146        if method.delivers_priced_work()
147            && matches!(self.result_type, ResultType::Absent)
148            && !self.is_error
149        {
150            self.result_type = ResultType::Complete;
151        }
152        self
153    }
154
155    /// A response carrying nothing the meter recognizes.
156    #[must_use]
157    pub const fn unrecognized() -> Self {
158        Self {
159            result_type: ResultType::Absent,
160            is_error: false,
161            task: None,
162        }
163    }
164}
165
166/// Task identity and state lifted out of a result.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct TaskPeek {
169    /// The durable handle. Doubles as the idempotency key for billing, because a
170    /// terminal task can be polled any number of times and every one of those
171    /// polls reports `completed`.
172    pub task_id: Option<String>,
173    /// Current task lifecycle status.
174    pub status: TaskStatus,
175}
176
177/// Inspect a request body for continuation markers.
178///
179/// Accepts an already-parsed [`Value`] so the caller can decide how the body was
180/// obtained (borrowed from a buffer in-process, or parsed once at the edge) and
181/// so this stays free of I/O.
182#[must_use]
183pub fn request(body: &Value) -> RequestPeek {
184    let Some(params) = body.get("params") else {
185        return RequestPeek::default();
186    };
187    RequestPeek {
188        is_continuation: params.get("inputResponses").is_some_and(|v| !v.is_null())
189            || params.get("requestState").is_some_and(|v| !v.is_null()),
190    }
191}
192
193/// Inspect a response body for delivery signals.
194#[must_use]
195pub fn response(body: &Value) -> ResponsePeek {
196    if body.get("error").is_some_and(|v| !v.is_null()) {
197        return ResponsePeek {
198            result_type: ResultType::Absent,
199            is_error: true,
200            task: None,
201        };
202    }
203
204    let Some(result) = body.get("result") else {
205        return ResponsePeek::unrecognized();
206    };
207
208    let result_type = match result.get("resultType").and_then(Value::as_str) {
209        Some("complete") => ResultType::Complete,
210        Some("input_required") => ResultType::InputRequired,
211        Some("task") => ResultType::Task,
212        Some(other) => ResultType::Unknown(other.to_owned()),
213        None => ResultType::Absent,
214    };
215
216    ResponsePeek {
217        result_type,
218        is_error: false,
219        task: peek_task(result),
220    }
221}
222
223/// Lift task identity and status out of a result.
224///
225/// Handles both shapes the extension produces: a `CreateTaskResult`, whose task
226/// fields sit on the result itself, and a `tasks/get` result, which *is* the task.
227/// Both put `status` and `taskId` at the top level of the result, so one lookup
228/// serves both.
229fn peek_task(result: &Value) -> Option<TaskPeek> {
230    let status = result.get("status").and_then(Value::as_str)?;
231    Some(TaskPeek {
232        task_id: result
233            .get("taskId")
234            .and_then(Value::as_str)
235            .map(str::to_owned),
236        status: TaskStatus::parse(status),
237    })
238}
239
240#[cfg(test)]
241mod tests {
242    use crate::{Call, Charge, Method, PriceBook, decide};
243
244    /// What a legacy server answers a lifecycle call with: a bare result.
245    fn bare_result() -> serde_json::Value {
246        json!({"jsonrpc": "2.0", "id": 1, "result": {}})
247    }
248
249    #[test]
250    fn the_legacy_promotion_reaches_only_methods_that_deliver_priced_work() {
251        // The promotion exists so a legacy `tools/call` is not free. Applied
252        // to every method it also bills `ping` and `initialize`, so an idle
253        // legacy client is charged for its keepalives forever while a modern
254        // client on identical traffic is charged nothing.
255        for method in ["tools/call", "resources/read", "prompts/get"] {
256            let promoted = response(&bare_result()).with_legacy_delivery(&Method::parse(method));
257            assert_eq!(
258                promoted.result_type,
259                ResultType::Complete,
260                "{method} delivers priced work and must still be promoted"
261            );
262        }
263
264        for method in [
265            "ping",
266            "initialize",
267            "notifications/initialized",
268            "logging/setLevel",
269            "resources/subscribe",
270            "completion/complete",
271            "vendor/extension",
272            "tools/list",
273            "tasks/get",
274        ] {
275            let promoted = response(&bare_result()).with_legacy_delivery(&Method::parse(method));
276            assert_eq!(
277                promoted.result_type,
278                ResultType::Absent,
279                "{method} delivers no priced work and must not be promoted"
280            );
281        }
282    }
283
284    #[test]
285    fn an_idle_legacy_client_is_billed_nothing_for_its_keepalives() {
286        // The end-to-end shape of the bug: a connected client pinging on a
287        // timer, billed at the flat rate on every beat.
288        let prices = PriceBook::flat(1);
289        for method in ["ping", "initialize", "logging/setLevel"] {
290            let parsed = Method::parse(method);
291            let observed = response(&bare_result()).with_legacy_delivery(&parsed);
292            let charge = decide(&Call::new(parsed, None), &observed, &prices);
293            assert!(
294                matches!(charge, Charge::Free(_)),
295                "{method} billed {charge:?} on the legacy path"
296            );
297        }
298    }
299
300    #[test]
301    fn a_legacy_tool_call_is_still_billed() {
302        // The regression guard for the fix itself: scoping the promotion must
303        // not make legacy tool calls free, which is what it exists to prevent.
304        let observed = response(&bare_result()).with_legacy_delivery(&Method::ToolsCall);
305        let charge = decide(
306            &Call::new(Method::ToolsCall, Some("search".to_owned())),
307            &observed,
308            &PriceBook::flat(3),
309        );
310        assert!(matches!(charge, Charge::Billable(b) if b.units == 3));
311    }
312
313    #[test]
314    fn a_legacy_error_is_never_promoted() {
315        let error = json!({"jsonrpc": "2.0", "id": 1, "error": {"code": -32603, "message": "x"}});
316        let promoted = response(&error).with_legacy_delivery(&Method::ToolsCall);
317        assert!(promoted.is_error);
318        assert_eq!(promoted.result_type, ResultType::Absent);
319    }
320
321    use super::*;
322    use serde_json::json;
323
324    #[test]
325    fn a_fresh_tool_call_is_not_a_continuation() {
326        let body = json!({
327            "jsonrpc": "2.0",
328            "id": 1,
329            "method": "tools/call",
330            "params": { "name": "get_weather", "arguments": { "location": "Seattle, WA" } }
331        });
332        assert!(!request(&body).is_continuation);
333    }
334
335    #[test]
336    fn input_responses_mark_a_continuation() {
337        // The retry shape from the Tools page: same tool, new id, plus responses.
338        let body = json!({
339            "jsonrpc": "2.0",
340            "id": 3,
341            "method": "tools/call",
342            "params": {
343                "name": "get_weather",
344                "arguments": { "location": "New York" },
345                "inputResponses": {
346                    "github_login": { "action": "accept", "content": { "name": "octocat" } }
347                },
348                "requestState": "eyJsb2NhdGlvbiI6Ik5ldyBZb3JrIn0..."
349            }
350        });
351        assert!(request(&body).is_continuation);
352    }
353
354    #[test]
355    fn request_state_alone_marks_a_continuation() {
356        // requestState is optional and may arrive without inputRequests, in which
357        // case the client "MAY retry the original request immediately".
358        let body = json!({
359            "jsonrpc": "2.0",
360            "id": 2,
361            "method": "tools/call",
362            "params": { "name": "t", "requestState": "AEAD-protected blob" }
363        });
364        assert!(request(&body).is_continuation);
365    }
366
367    #[test]
368    fn we_never_look_inside_request_state() {
369        // Clients MUST NOT parse it and neither do we: an opaque, possibly
370        // encrypted blob is treated purely as a presence flag.
371        for state in [
372            json!("AEAD-protected blob"),
373            json!(""),
374            json!("////not base64"),
375        ] {
376            let body = json!({ "method": "tools/call", "params": { "requestState": state } });
377            assert!(request(&body).is_continuation);
378        }
379    }
380
381    #[test]
382    fn null_markers_do_not_count_as_present() {
383        let body = json!({
384            "method": "tools/call",
385            "params": { "name": "t", "inputResponses": null, "requestState": null }
386        });
387        assert!(!request(&body).is_continuation);
388    }
389
390    #[test]
391    fn a_body_without_params_is_not_a_continuation() {
392        assert!(!request(&json!({ "method": "tools/list" })).is_continuation);
393        assert!(!request(&json!({})).is_continuation);
394    }
395
396    #[test]
397    fn reads_the_three_result_types() {
398        let complete = json!({ "id": 2, "result": { "resultType": "complete", "content": [] } });
399        assert_eq!(response(&complete).result_type, ResultType::Complete);
400
401        let interim = json!({
402            "id": 1,
403            "result": {
404                "resultType": "input_required",
405                "inputRequests": { "github_login": { "method": "elicitation/create" } },
406                "requestState": "AEAD-protected blob"
407            }
408        });
409        assert_eq!(response(&interim).result_type, ResultType::InputRequired);
410
411        let task = json!({
412            "id": 1,
413            "result": { "resultType": "task", "taskId": "tsk_1", "status": "working", "ttlMs": 60000 }
414        });
415        assert_eq!(response(&task).result_type, ResultType::Task);
416    }
417
418    #[test]
419    fn errors_are_distinguished_from_results() {
420        let err = json!({
421            "jsonrpc": "2.0",
422            "id": 1,
423            "error": { "code": -32020, "message": "Header mismatch" }
424        });
425        let peek = response(&err);
426        assert!(peek.is_error);
427        assert_eq!(peek.result_type, ResultType::Absent);
428    }
429
430    #[test]
431    fn unknown_result_types_are_preserved_not_coerced() {
432        let body = json!({ "result": { "resultType": "some_future_shape" } });
433        assert_eq!(
434            response(&body).result_type,
435            ResultType::Unknown("some_future_shape".to_owned())
436        );
437    }
438
439    #[test]
440    fn task_polls_carry_status_and_id() {
441        let working = json!({
442            "result": { "resultType": "complete", "taskId": "tsk_abc", "status": "working" }
443        });
444        let peek = response(&working);
445        let task = peek.task.expect("task present");
446        assert_eq!(task.status, TaskStatus::Working);
447        assert_eq!(task.task_id.as_deref(), Some("tsk_abc"));
448        assert!(!task.status.is_terminal());
449        assert!(!task.status.delivered());
450    }
451
452    #[test]
453    fn terminal_statuses_are_classified_correctly() {
454        assert!(TaskStatus::Completed.is_terminal() && TaskStatus::Completed.delivered());
455        // Terminal, but nothing was delivered, so nothing is owed.
456        assert!(TaskStatus::Failed.is_terminal() && !TaskStatus::Failed.delivered());
457        assert!(TaskStatus::Cancelled.is_terminal() && !TaskStatus::Cancelled.delivered());
458        assert!(!TaskStatus::Working.is_terminal() && !TaskStatus::Working.delivered());
459        assert!(!TaskStatus::InputRequired.is_terminal());
460        let unknown = TaskStatus::Unknown("paused".to_owned());
461        assert!(!unknown.is_terminal() && !unknown.delivered());
462    }
463
464    #[test]
465    fn task_status_round_trips_through_display() {
466        for raw in [
467            "working",
468            "input_required",
469            "completed",
470            "failed",
471            "cancelled",
472        ] {
473            assert_eq!(TaskStatus::parse(raw).to_string(), raw);
474        }
475    }
476
477    #[test]
478    fn a_plain_result_carries_no_task() {
479        let body = json!({ "result": { "resultType": "complete", "content": [] } });
480        assert!(response(&body).task.is_none());
481    }
482
483    #[test]
484    fn a_response_with_neither_result_nor_error_is_unrecognized() {
485        assert_eq!(
486            response(&json!({ "jsonrpc": "2.0" })),
487            ResponsePeek::unrecognized()
488        );
489    }
490}