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