Skip to main content

headless_lms_mcp/protocol/
context.rs

1//! Per-request client context from `_meta`. Every feature reads it here and has a fallback, so
2//! correctness never depends on what a client declares.
3
4use rmcp::model::{
5    ClientCapabilities, Implementation, MetaObject, ProgressToken, ProtocolVersion,
6    RequestMetaObject,
7};
8use serde_json::{Map, Value};
9
10const META_PROGRESS_TOKEN: &str = "progressToken";
11
12/// Clients whose wording or limits differ.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum ClientKind {
15    ClaudeCode,
16    Codex,
17    Other,
18}
19
20impl ClientKind {
21    /// The kind `client_info` names; `Other` without one.
22    fn of(client_info: Option<&Implementation>) -> Self {
23        let name = client_info
24            .map(|c| c.name.to_lowercase())
25            .unwrap_or_default();
26        if name.contains("claude") {
27            ClientKind::ClaudeCode
28        } else if name.contains("codex") {
29            ClientKind::Codex
30        } else {
31            ClientKind::Other
32        }
33    }
34}
35
36/// The calling client and what it declared in the request's `_meta`.
37#[derive(Debug, Clone)]
38pub struct ClientContext {
39    pub protocol_version: Option<ProtocolVersion>,
40    pub capabilities: ClientCapabilities,
41    pub progress_token: Option<ProgressToken>,
42    kind: ClientKind,
43}
44
45impl ClientContext {
46    /// The context a request's `_meta` declares; missing fields fall back to defaults.
47    pub fn from_meta(meta: Map<String, Value>) -> Self {
48        let progress_token = meta
49            .get(META_PROGRESS_TOKEN)
50            .and_then(|token| serde_json::from_value(token.clone()).ok());
51        let request_meta = RequestMetaObject(MetaObject(meta));
52        Self {
53            protocol_version: request_meta.protocol_version(),
54            kind: ClientKind::of(request_meta.client_info().as_ref()),
55            capabilities: request_meta.client_capabilities().unwrap_or_default(),
56            progress_token,
57        }
58    }
59
60    /// The kind of client, guessed from its `client_info` name.
61    pub fn client_kind(&self) -> ClientKind {
62        self.kind
63    }
64
65    /// Whether the client declared support for URL elicitation.
66    pub fn supports_url_elicitation(&self) -> bool {
67        self.capabilities
68            .elicitation
69            .as_ref()
70            .is_some_and(|e| e.url.is_some())
71    }
72
73    /// Whether an `input_required` result will reach the human. Codex's opt-in 2026-07-28 path
74    /// errors on these results, so it gets the text fallback.
75    pub fn can_use_input_required(&self) -> bool {
76        self.client_kind() != ClientKind::Codex
77    }
78
79    /// Serialized result size reads aim under: Codex drops the middle of results over about
80    /// 10 KB, Claude Code moves results over about 50k characters to a file.
81    pub fn result_budget_bytes(&self) -> usize {
82        match self.client_kind() {
83            ClientKind::Codex => 8_000,
84            ClientKind::ClaudeCode => 40_000,
85            ClientKind::Other => 16_000,
86        }
87    }
88
89    /// A smaller budget for `read_back`, which follows a write summary in the same result.
90    pub fn read_back_budget_bytes(&self) -> usize {
91        self.result_budget_bytes() / 2
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use rmcp::model::NumberOrString;
98    use serde_json::json;
99
100    use super::*;
101
102    fn context(meta: Value) -> ClientContext {
103        ClientContext::from_meta(meta.as_object().unwrap().clone())
104    }
105
106    #[test]
107    fn reads_client_info_and_capabilities() {
108        let ctx = context(json!({
109            "io.modelcontextprotocol/protocolVersion": "2026-07-28",
110            "io.modelcontextprotocol/clientInfo": {"name": "claude-code", "version": "3.0.0"},
111            "io.modelcontextprotocol/clientCapabilities": {"elicitation": {"url": {}}},
112            "progressToken": 7,
113            "traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
114        }));
115        assert_eq!(ctx.client_kind(), ClientKind::ClaudeCode);
116        assert!(ctx.supports_url_elicitation());
117        assert!(ctx.can_use_input_required());
118        assert_eq!(
119            ctx.progress_token,
120            Some(ProgressToken(NumberOrString::Number(7)))
121        );
122        assert_eq!(ctx.protocol_version, Some(ProtocolVersion::V_2026_07_28));
123    }
124
125    #[test]
126    fn codex_gets_fallbacks() {
127        let ctx = context(json!({
128            "io.modelcontextprotocol/clientInfo": {"name": "codex-mcp-client", "version": "0.9"},
129            "io.modelcontextprotocol/clientCapabilities": {"elicitation": {"url": {}}},
130        }));
131        assert!(!ctx.can_use_input_required());
132        assert_eq!(ctx.result_budget_bytes(), 8_000);
133        assert!(!context(json!({})).supports_url_elicitation());
134    }
135}