Skip to main content

sim_codec_mcp/
expr.rs

1//! Conversion between MCP envelopes and checked `Expr` values:
2//! `envelope_to_expr` projects an envelope to its canonical map, and
3//! `expr_to_envelope` validates a map back into a typed envelope.
4
5use std::collections::BTreeSet;
6
7use sim_kernel::{Error, Expr, NumberLiteral, Result, Symbol};
8
9use crate::envelope::{
10    McpEnvelope, McpError, McpErrorEnvelope, McpNotification, McpRequest, McpResponse,
11    is_jsonrpc_id,
12};
13
14const MCP_VERSION: &str = "2.0";
15
16/// Project an [`McpEnvelope`] into its canonical `Expr` map, with the `mcp`
17/// version field and the variant-specific fields.
18///
19/// # Examples
20///
21/// ```
22/// use sim_codec_mcp::{McpEnvelope, McpRequest, envelope_to_expr};
23///
24/// let envelope = McpEnvelope::Request(McpRequest::default());
25/// let expr = envelope_to_expr(&envelope);
26/// // Round-trips back to the same typed envelope.
27/// assert_eq!(sim_codec_mcp::expr_to_envelope(&expr).unwrap(), envelope);
28/// ```
29pub fn envelope_to_expr(envelope: &McpEnvelope) -> Expr {
30    match envelope {
31        McpEnvelope::Request(request) => Expr::Map(vec![
32            field("mcp", Expr::String(MCP_VERSION.to_owned())),
33            field("id", request.id.clone()),
34            field("method", Expr::String(request.method.clone())),
35            field("params", request.params.clone()),
36        ]),
37        McpEnvelope::Notification(notification) => Expr::Map(vec![
38            field("mcp", Expr::String(MCP_VERSION.to_owned())),
39            field("method", Expr::String(notification.method.clone())),
40            field("params", notification.params.clone()),
41        ]),
42        McpEnvelope::Response(response) => Expr::Map(vec![
43            field("mcp", Expr::String(MCP_VERSION.to_owned())),
44            field("id", response.id.clone()),
45            field("result", response.result.clone()),
46        ]),
47        McpEnvelope::Error(error) => Expr::Map(vec![
48            field("mcp", Expr::String(MCP_VERSION.to_owned())),
49            field("id", error.id.clone()),
50            field(
51                "error",
52                Expr::Map(vec![
53                    field("code", error_code_expr(error.error.code)),
54                    field("message", Expr::String(error.error.message.clone())),
55                    field("data", error.error.data.clone()),
56                ]),
57            ),
58        ]),
59    }
60}
61
62/// Validate a canonical `Expr` map back into a typed [`McpEnvelope`].
63///
64/// The map must declare `mcp: "2.0"` and exactly the field set of one envelope
65/// variant; unknown, duplicate, or mismatched fields are rejected, so the
66/// codec fails closed on non-MCP input.
67///
68/// # Examples
69///
70/// ```
71/// use sim_codec_mcp::{McpEnvelope, McpResponse, envelope_to_expr, expr_to_envelope};
72///
73/// let expr = envelope_to_expr(&McpEnvelope::Response(McpResponse::default()));
74/// assert!(matches!(expr_to_envelope(&expr).unwrap(), McpEnvelope::Response(_)));
75/// ```
76pub fn expr_to_envelope(expr: &Expr) -> Result<McpEnvelope> {
77    let fields = map_fields(expr, "MCP envelope")?;
78    reject_unknown(
79        fields,
80        &["mcp", "id", "method", "params", "result", "error"],
81    )?;
82    require_version(fields)?;
83
84    let has_id = optional_field(fields, "id").is_some();
85    let has_method = optional_field(fields, "method").is_some();
86    let has_result = optional_field(fields, "result").is_some();
87    let has_error = optional_field(fields, "error").is_some();
88
89    match (has_method, has_id, has_result, has_error) {
90        (true, true, false, false) => request_from_fields(fields),
91        (true, false, false, false) => notification_from_fields(fields),
92        (false, true, true, false) => response_from_fields(fields),
93        (false, true, false, true) => error_from_fields(fields),
94        _ => Err(Error::Eval(
95            "invalid MCP JSON-RPC envelope field combination".to_owned(),
96        )),
97    }
98}
99
100fn request_from_fields(fields: &[(Expr, Expr)]) -> Result<McpEnvelope> {
101    reject_unknown(fields, &["mcp", "id", "method", "params"])?;
102    let id = required_id(fields)?;
103    let method = required_string(fields, "method")?;
104    let params = required_field(fields, "params")?.clone();
105    Ok(McpEnvelope::Request(McpRequest { id, method, params }))
106}
107
108fn notification_from_fields(fields: &[(Expr, Expr)]) -> Result<McpEnvelope> {
109    reject_unknown(fields, &["mcp", "method", "params"])?;
110    let method = required_string(fields, "method")?;
111    let params = required_field(fields, "params")?.clone();
112    Ok(McpEnvelope::Notification(McpNotification {
113        method,
114        params,
115    }))
116}
117
118fn response_from_fields(fields: &[(Expr, Expr)]) -> Result<McpEnvelope> {
119    reject_unknown(fields, &["mcp", "id", "result"])?;
120    let id = required_id(fields)?;
121    let result = required_field(fields, "result")?.clone();
122    Ok(McpEnvelope::Response(McpResponse { id, result }))
123}
124
125fn error_from_fields(fields: &[(Expr, Expr)]) -> Result<McpEnvelope> {
126    reject_unknown(fields, &["mcp", "id", "error"])?;
127    let id = required_id(fields)?;
128    let error = error_object(required_field(fields, "error")?)?;
129    Ok(McpEnvelope::Error(McpErrorEnvelope { id, error }))
130}
131
132fn error_object(expr: &Expr) -> Result<McpError> {
133    let fields = map_fields(expr, "MCP error object")?;
134    reject_unknown(fields, &["code", "message", "data"])?;
135    Ok(McpError {
136        code: required_i64(fields, "code")?,
137        message: required_string(fields, "message")?,
138        data: required_field(fields, "data")?.clone(),
139    })
140}
141
142fn require_version(fields: &[(Expr, Expr)]) -> Result<()> {
143    match required_field(fields, "mcp")? {
144        Expr::String(version) if version == MCP_VERSION => Ok(()),
145        _ => Err(Error::Eval(
146            "MCP envelope must declare :mcp \"2.0\"".to_owned(),
147        )),
148    }
149}
150
151fn required_id(fields: &[(Expr, Expr)]) -> Result<Expr> {
152    let id = required_field(fields, "id")?.clone();
153    if is_jsonrpc_id(&id) {
154        Ok(id)
155    } else {
156        Err(Error::TypeMismatch {
157            expected: "JSON-RPC id string, number, or nil",
158            found: "invalid id",
159        })
160    }
161}
162
163fn required_i64(fields: &[(Expr, Expr)], name: &str) -> Result<i64> {
164    match required_field(fields, name)? {
165        Expr::Number(number) => number
166            .canonical
167            .parse::<i64>()
168            .map_err(|_| Error::TypeMismatch {
169                expected: "integer error code",
170                found: "non-integer number",
171            }),
172        _ => Err(Error::TypeMismatch {
173            expected: "integer error code",
174            found: "non-number",
175        }),
176    }
177}
178
179fn required_string(fields: &[(Expr, Expr)], name: &str) -> Result<String> {
180    match required_field(fields, name)? {
181        Expr::String(value) => Ok(value.clone()),
182        _ => Err(Error::TypeMismatch {
183            expected: "string",
184            found: "non-string",
185        }),
186    }
187}
188
189fn required_field<'a>(fields: &'a [(Expr, Expr)], name: &str) -> Result<&'a Expr> {
190    optional_field(fields, name)
191        .ok_or_else(|| Error::Eval(format!("MCP envelope is missing {name}")))
192}
193
194fn optional_field<'a>(fields: &'a [(Expr, Expr)], name: &str) -> Option<&'a Expr> {
195    fields
196        .iter()
197        .find_map(|(key, value)| (field_name(key).ok()?.as_str() == name).then_some(value))
198}
199
200use sim_value::access::map_entries as map_fields;
201
202fn reject_unknown(fields: &[(Expr, Expr)], allowed: &[&str]) -> Result<()> {
203    let mut seen = BTreeSet::new();
204    for (key, _) in fields {
205        let name = field_name(key)?;
206        if !seen.insert(name.clone()) {
207            return Err(Error::Eval(format!("duplicate MCP envelope field {name}")));
208        }
209        if !allowed.contains(&name.as_str()) {
210            return Err(Error::Eval(format!("unknown MCP envelope field {name}")));
211        }
212    }
213    Ok(())
214}
215
216fn field_name(expr: &Expr) -> Result<String> {
217    match expr {
218        Expr::Symbol(symbol) if symbol.namespace.is_none() => Ok(symbol.name.to_string()),
219        Expr::String(value) => Ok(value.clone()),
220        _ => Err(Error::TypeMismatch {
221            expected: "MCP envelope field symbol",
222            found: "invalid field key",
223        }),
224    }
225}
226
227fn field(name: &str, value: Expr) -> (Expr, Expr) {
228    sim_value::build::entry(name, value)
229}
230
231pub(crate) fn error_code_expr(code: i64) -> Expr {
232    Expr::Number(NumberLiteral {
233        domain: Symbol::qualified("numbers", "i64"),
234        canonical: code.to_string(),
235    })
236}