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    #[must_use]
139    pub fn with_legacy_delivery(mut self) -> Self {
140        if matches!(self.result_type, ResultType::Absent) && !self.is_error {
141            self.result_type = ResultType::Complete;
142        }
143        self
144    }
145
146    /// A response carrying nothing the meter recognizes.
147    #[must_use]
148    pub const fn unrecognized() -> Self {
149        Self {
150            result_type: ResultType::Absent,
151            is_error: false,
152            task: None,
153        }
154    }
155}
156
157/// Task identity and state lifted out of a result.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct TaskPeek {
160    /// The durable handle. Doubles as the idempotency key for billing, because a
161    /// terminal task can be polled any number of times and every one of those
162    /// polls reports `completed`.
163    pub task_id: Option<String>,
164    /// Current task lifecycle status.
165    pub status: TaskStatus,
166}
167
168/// Inspect a request body for continuation markers.
169///
170/// Accepts an already-parsed [`Value`] so the caller can decide how the body was
171/// obtained (borrowed from a buffer in-process, or parsed once at the edge) and
172/// so this stays free of I/O.
173#[must_use]
174pub fn request(body: &Value) -> RequestPeek {
175    let Some(params) = body.get("params") else {
176        return RequestPeek::default();
177    };
178    RequestPeek {
179        is_continuation: params.get("inputResponses").is_some_and(|v| !v.is_null())
180            || params.get("requestState").is_some_and(|v| !v.is_null()),
181    }
182}
183
184/// Inspect a response body for delivery signals.
185#[must_use]
186pub fn response(body: &Value) -> ResponsePeek {
187    if body.get("error").is_some_and(|v| !v.is_null()) {
188        return ResponsePeek {
189            result_type: ResultType::Absent,
190            is_error: true,
191            task: None,
192        };
193    }
194
195    let Some(result) = body.get("result") else {
196        return ResponsePeek::unrecognized();
197    };
198
199    let result_type = match result.get("resultType").and_then(Value::as_str) {
200        Some("complete") => ResultType::Complete,
201        Some("input_required") => ResultType::InputRequired,
202        Some("task") => ResultType::Task,
203        Some(other) => ResultType::Unknown(other.to_owned()),
204        None => ResultType::Absent,
205    };
206
207    ResponsePeek {
208        result_type,
209        is_error: false,
210        task: peek_task(result),
211    }
212}
213
214/// Lift task identity and status out of a result.
215///
216/// Handles both shapes the extension produces: a `CreateTaskResult`, whose task
217/// fields sit on the result itself, and a `tasks/get` result, which *is* the task.
218/// Both put `status` and `taskId` at the top level of the result, so one lookup
219/// serves both.
220fn peek_task(result: &Value) -> Option<TaskPeek> {
221    let status = result.get("status").and_then(Value::as_str)?;
222    Some(TaskPeek {
223        task_id: result
224            .get("taskId")
225            .and_then(Value::as_str)
226            .map(str::to_owned),
227        status: TaskStatus::parse(status),
228    })
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use serde_json::json;
235
236    #[test]
237    fn a_fresh_tool_call_is_not_a_continuation() {
238        let body = json!({
239            "jsonrpc": "2.0",
240            "id": 1,
241            "method": "tools/call",
242            "params": { "name": "get_weather", "arguments": { "location": "Seattle, WA" } }
243        });
244        assert!(!request(&body).is_continuation);
245    }
246
247    #[test]
248    fn input_responses_mark_a_continuation() {
249        // The retry shape from the Tools page: same tool, new id, plus responses.
250        let body = json!({
251            "jsonrpc": "2.0",
252            "id": 3,
253            "method": "tools/call",
254            "params": {
255                "name": "get_weather",
256                "arguments": { "location": "New York" },
257                "inputResponses": {
258                    "github_login": { "action": "accept", "content": { "name": "octocat" } }
259                },
260                "requestState": "eyJsb2NhdGlvbiI6Ik5ldyBZb3JrIn0..."
261            }
262        });
263        assert!(request(&body).is_continuation);
264    }
265
266    #[test]
267    fn request_state_alone_marks_a_continuation() {
268        // requestState is optional and may arrive without inputRequests, in which
269        // case the client "MAY retry the original request immediately".
270        let body = json!({
271            "jsonrpc": "2.0",
272            "id": 2,
273            "method": "tools/call",
274            "params": { "name": "t", "requestState": "AEAD-protected blob" }
275        });
276        assert!(request(&body).is_continuation);
277    }
278
279    #[test]
280    fn we_never_look_inside_request_state() {
281        // Clients MUST NOT parse it and neither do we: an opaque, possibly
282        // encrypted blob is treated purely as a presence flag.
283        for state in [
284            json!("AEAD-protected blob"),
285            json!(""),
286            json!("////not base64"),
287        ] {
288            let body = json!({ "method": "tools/call", "params": { "requestState": state } });
289            assert!(request(&body).is_continuation);
290        }
291    }
292
293    #[test]
294    fn null_markers_do_not_count_as_present() {
295        let body = json!({
296            "method": "tools/call",
297            "params": { "name": "t", "inputResponses": null, "requestState": null }
298        });
299        assert!(!request(&body).is_continuation);
300    }
301
302    #[test]
303    fn a_body_without_params_is_not_a_continuation() {
304        assert!(!request(&json!({ "method": "tools/list" })).is_continuation);
305        assert!(!request(&json!({})).is_continuation);
306    }
307
308    #[test]
309    fn reads_the_three_result_types() {
310        let complete = json!({ "id": 2, "result": { "resultType": "complete", "content": [] } });
311        assert_eq!(response(&complete).result_type, ResultType::Complete);
312
313        let interim = json!({
314            "id": 1,
315            "result": {
316                "resultType": "input_required",
317                "inputRequests": { "github_login": { "method": "elicitation/create" } },
318                "requestState": "AEAD-protected blob"
319            }
320        });
321        assert_eq!(response(&interim).result_type, ResultType::InputRequired);
322
323        let task = json!({
324            "id": 1,
325            "result": { "resultType": "task", "taskId": "tsk_1", "status": "working", "ttlMs": 60000 }
326        });
327        assert_eq!(response(&task).result_type, ResultType::Task);
328    }
329
330    #[test]
331    fn errors_are_distinguished_from_results() {
332        let err = json!({
333            "jsonrpc": "2.0",
334            "id": 1,
335            "error": { "code": -32020, "message": "Header mismatch" }
336        });
337        let peek = response(&err);
338        assert!(peek.is_error);
339        assert_eq!(peek.result_type, ResultType::Absent);
340    }
341
342    #[test]
343    fn unknown_result_types_are_preserved_not_coerced() {
344        let body = json!({ "result": { "resultType": "some_future_shape" } });
345        assert_eq!(
346            response(&body).result_type,
347            ResultType::Unknown("some_future_shape".to_owned())
348        );
349    }
350
351    #[test]
352    fn task_polls_carry_status_and_id() {
353        let working = json!({
354            "result": { "resultType": "complete", "taskId": "tsk_abc", "status": "working" }
355        });
356        let peek = response(&working);
357        let task = peek.task.expect("task present");
358        assert_eq!(task.status, TaskStatus::Working);
359        assert_eq!(task.task_id.as_deref(), Some("tsk_abc"));
360        assert!(!task.status.is_terminal());
361        assert!(!task.status.delivered());
362    }
363
364    #[test]
365    fn terminal_statuses_are_classified_correctly() {
366        assert!(TaskStatus::Completed.is_terminal() && TaskStatus::Completed.delivered());
367        // Terminal, but nothing was delivered, so nothing is owed.
368        assert!(TaskStatus::Failed.is_terminal() && !TaskStatus::Failed.delivered());
369        assert!(TaskStatus::Cancelled.is_terminal() && !TaskStatus::Cancelled.delivered());
370        assert!(!TaskStatus::Working.is_terminal() && !TaskStatus::Working.delivered());
371        assert!(!TaskStatus::InputRequired.is_terminal());
372        let unknown = TaskStatus::Unknown("paused".to_owned());
373        assert!(!unknown.is_terminal() && !unknown.delivered());
374    }
375
376    #[test]
377    fn task_status_round_trips_through_display() {
378        for raw in [
379            "working",
380            "input_required",
381            "completed",
382            "failed",
383            "cancelled",
384        ] {
385            assert_eq!(TaskStatus::parse(raw).to_string(), raw);
386        }
387    }
388
389    #[test]
390    fn a_plain_result_carries_no_task() {
391        let body = json!({ "result": { "resultType": "complete", "content": [] } });
392        assert!(response(&body).task.is_none());
393    }
394
395    #[test]
396    fn a_response_with_neither_result_nor_error_is_unrecognized() {
397        assert_eq!(
398            response(&json!({ "jsonrpc": "2.0" })),
399            ResponsePeek::unrecognized()
400        );
401    }
402}