Skip to main content

haematite_cli/mcp/
adapter.rs

1use std::fmt;
2
3#[path = "adapter/describe.rs"]
4mod describe;
5
6#[path = "adapter/history.rs"]
7mod history;
8
9#[path = "adapter/read_support.rs"]
10mod read_support;
11
12#[path = "adapter/review.rs"]
13mod review;
14
15#[path = "adapter/review_lines.rs"]
16mod review_lines;
17
18#[path = "adapter/writes.rs"]
19mod writes;
20
21#[cfg(test)]
22#[path = "adapter/describe_tests.rs"]
23mod describe_tests;
24
25#[cfg(test)]
26#[path = "adapter/history_tests.rs"]
27mod history_tests;
28
29#[cfg(test)]
30#[path = "adapter/review_tests.rs"]
31mod review_tests;
32
33#[cfg(test)]
34#[path = "adapter/writes_tests.rs"]
35mod writes_tests;
36
37use serde::{Deserialize, Deserializer, Serialize};
38use serde_json::value::RawValue;
39
40pub use writes::ASSISTANT_RECORD;
41
42use super::{rpc, types};
43use crate::serve::protocol;
44
45/// Sends a request under the transport's established authority and returns
46/// its raw answer. The adapter neither opens storage nor establishes access.
47pub trait Sender {
48    /// # Errors
49    ///
50    /// Returns the service's refusal or a named transport failure. An answer
51    /// must retain its JSON number spelling rather than pass through `Value`.
52    fn send(&mut self, request: protocol::Operation) -> Result<Box<RawValue>, ServiceRefusal>;
53}
54
55#[derive(Debug, Serialize, Deserialize)]
56pub struct ServiceRefusal {
57    pub code: String,
58    pub message: String,
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub receipt: Option<Box<RawValue>>,
61}
62
63#[derive(Debug, Serialize)]
64#[serde(tag = "type", rename_all = "lowercase")]
65pub enum TextContent {
66    Text { text: String },
67}
68
69#[derive(Debug)]
70pub struct ToolResult {
71    pub content: [TextContent; 1],
72    pub structured_content: Box<RawValue>,
73    pub is_error: bool,
74}
75
76impl Serialize for ToolResult {
77    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
78        #[derive(Serialize)]
79        #[serde(rename_all = "camelCase")]
80        struct Encoded<'a> {
81            content: &'a [TextContent; 1],
82            #[serde(skip_serializing_if = "Option::is_none")]
83            structured_content: Option<&'a RawValue>,
84            is_error: bool,
85        }
86        Encoded {
87            content: &self.content,
88            structured_content: Some(self.structured_content.as_ref()),
89            is_error: self.is_error,
90        }
91        .serialize(serializer)
92    }
93}
94
95#[derive(Debug)]
96pub struct EncodeError {
97    cause: serde_json::Error,
98    receipt: Option<Box<RawValue>>,
99}
100
101impl fmt::Display for EncodeError {
102    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
103        write!(
104            formatter,
105            "mcp_result_encoding_failed: {}; retained receipt {}",
106            self.cause,
107            self.receipt.is_some()
108        )
109    }
110}
111
112impl std::error::Error for EncodeError {
113    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
114        Some(&self.cause)
115    }
116}
117
118/// Adapts one tool call without retries or continuation draining.
119///
120/// # Errors
121///
122/// Returns `mcp_result_encoding_failed` if the tool result cannot be encoded.
123/// Argument and service refusals are tool results, not JSON-RPC errors.
124/// A write naming the assistant's record collection is refused (`assistant_record_reserved`).
125pub fn dispatch(sender: &mut impl Sender, call: &rpc::ToolCall) -> Result<ToolResult, EncodeError> {
126    dispatch_as(sender, writes::Writer::Public, call)
127}
128
129/// The assistant recorder's own writes: the one caller that may write the assistant's record collection.
130///
131/// # Errors
132///
133/// As [`dispatch`].
134pub fn dispatch_assistant_record(
135    sender: &mut impl Sender,
136    call: &rpc::ToolCall,
137) -> Result<ToolResult, EncodeError> {
138    dispatch_as(sender, writes::Writer::AssistantRecorder, call)
139}
140
141fn dispatch_as(
142    sender: &mut impl Sender,
143    writer: writes::Writer,
144    call: &rpc::ToolCall,
145) -> Result<ToolResult, EncodeError> {
146    match call.name.as_str() {
147        "describe_store" => return describe::dispatch(sender, call),
148        "read_history" => return history::dispatch(sender, call),
149        "review_branch" => return review::dispatch(sender, call),
150        "define_collection" => return writes::define_collection(sender, writer, call),
151        "put_records" => return writes::put_records(sender, writer, call),
152        "delete_record" => return writes::delete_record(sender, writer, call),
153        "link_records" => return writes::link_records(sender, writer, call),
154        "unlink_records" => return writes::unlink_records(sender, writer, call),
155        "create_branch" => return writes::create_branch(sender, writer, call),
156        "merge_branch" => return writes::merge_branch(sender, writer, call),
157        _ => {}
158    }
159    if call.name != "run_query" {
160        return refused(&ServiceRefusal {
161            code: "mcp_tool_unknown".into(),
162            message: format!("{} is not a registered tool", call.name),
163            receipt: None,
164        });
165    }
166    let input: types::RunQueryInput = match serde_json::from_str(call.arguments.get()) {
167        Ok(input) => input,
168        Err(error) => {
169            return refused(&ServiceRefusal {
170                code: "mcp_arguments_invalid".into(),
171                message: error.to_string(),
172                receipt: None,
173            });
174        }
175    };
176    let requested = input.selection;
177    let bytes = input.page.bytes.get();
178    let request = protocol::Operation::QueryRun(protocol::QueryRequest {
179        selection: protocol::Selection {
180            branch: requested.branch.clone(),
181            version: requested.version.map(|version| version.0),
182        },
183        query: input.query,
184        parameters: input.parameters,
185        page: protocol::QueryPage {
186            rows: input.page.rows.into(),
187            bytes: input.page.bytes,
188        },
189        continuation: input.after,
190        rows: crate::serve::query::Rows::Values,
191    });
192    let raw = match sender.send(request) {
193        Ok(raw) => raw,
194        Err(refusal) => return refused(&refusal),
195    };
196    let answer: QueryAnswer = match serde_json::from_str(raw.get()) {
197        Ok(answer) => answer,
198        Err(error) => {
199            return refused(&ServiceRefusal {
200                code: "mcp_service_answer_invalid".into(),
201                message: error.to_string(),
202                receipt: None,
203            });
204        }
205    };
206    if let Err(error) = answer.selection.requested(&requested) {
207        return refused(&error);
208    }
209    let output = types::RunQueryOutput {
210        selection: answer.selection.output(),
211        rows: types::Page {
212            entries: answer
213                .rows
214                .into_iter()
215                .map(|row| types::QueryRow {
216                    value: row.value,
217                    origin: row.origin.map(|origin| types::RecordRef {
218                        collection: origin.collection,
219                        document_id: origin.id,
220                    }),
221                })
222                .collect(),
223            next_after: answer.next,
224        },
225    };
226    match read_support::bounded(&output, bytes) {
227        Ok(output) => Ok(output),
228        Err(error) => refused(&error),
229    }
230}
231
232fn refused(refusal: &ServiceRefusal) -> Result<ToolResult, EncodeError> {
233    let structured_content =
234        serde_json::value::to_raw_value(refusal).map_err(|cause| EncodeError {
235            cause,
236            receipt: refusal.receipt.clone(),
237        })?;
238    let text = structured_content.get().to_owned();
239    Ok(ToolResult {
240        content: [TextContent::Text { text }],
241        structured_content,
242        is_error: true,
243    })
244}
245
246fn result(body: &impl Serialize, is_error: bool) -> Result<ToolResult, EncodeError> {
247    let structured_content =
248        serde_json::value::to_raw_value(body).map_err(|cause| EncodeError {
249            cause,
250            receipt: None,
251        })?;
252    let text = structured_content.get().to_owned();
253    Ok(ToolResult {
254        content: [TextContent::Text { text }],
255        structured_content,
256        is_error,
257    })
258}
259
260// Service diagnostics are outside the tool's output shape. Project only its
261// required members and retain each row body as raw JSON.
262#[derive(Deserialize)]
263struct QueryAnswer {
264    selection: read_support::Snapshot,
265    rows: Vec<QueryRow>,
266    #[serde(deserialize_with = "present_next")]
267    next: Option<String>,
268}
269
270#[derive(Deserialize)]
271struct QueryRow {
272    value: Box<RawValue>,
273    #[serde(deserialize_with = "present_origin")]
274    origin: Option<QueryOrigin>,
275}
276
277#[derive(Deserialize)]
278struct QueryOrigin {
279    collection: String,
280    id: String,
281}
282
283fn present_next<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<String>, D::Error> {
284    Option::deserialize(deserializer)
285}
286
287fn present_origin<'de, D: Deserializer<'de>>(
288    deserializer: D,
289) -> Result<Option<QueryOrigin>, D::Error> {
290    Option::deserialize(deserializer)
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    type TestResult = Result<(), Box<dyn std::error::Error>>;
298
299    const ARGUMENTS: &str = r#"{"selection":{"branch":"main","version":{"generation":"7","sequence":"11"}},"query":".amount","parameters":{"amount":1e2,"identity":123456789012345678},"page":{"rows":17,"bytes":4096},"after":"opaque+/=cursor"}"#;
300    const ANSWER: &str = r#"{"selection":{"branch":"main","version":{"generation":"7","sequence":"11"},"roots":[]},"rows":[{"value":{"amount":1e2,"identity":123456789012345678},"origin":{"collection":"accounts","id":"a","version":11,"score":null}}],"next":"next+/=cursor","engine":"jq","records_read":1}"#;
301
302    struct RecordingSender {
303        requests: Vec<protocol::Operation>,
304        answer: Option<Result<Box<RawValue>, ServiceRefusal>>,
305    }
306
307    impl RecordingSender {
308        fn answered(text: &str) -> Result<Self, serde_json::Error> {
309            Ok(Self {
310                requests: Vec::new(),
311                answer: Some(Ok(serde_json::from_str(text)?)),
312            })
313        }
314    }
315
316    impl Sender for RecordingSender {
317        fn send(&mut self, request: protocol::Operation) -> Result<Box<RawValue>, ServiceRefusal> {
318            self.requests.push(request);
319            self.answer.take().ok_or_else(|| ServiceRefusal {
320                code: "test_unexpected_request".into(),
321                message: "no further answer was supplied".into(),
322                receipt: None,
323            })?
324        }
325    }
326
327    fn call(name: &str, arguments: &str) -> Result<rpc::ToolCall, serde_json::Error> {
328        Ok(rpc::ToolCall {
329            name: name.into(),
330            arguments: serde_json::from_str(arguments)?,
331            metadata: None,
332        })
333    }
334
335    fn assert_refusal(result: &ToolResult, code: &str) -> TestResult {
336        let refusal: ServiceRefusal = serde_json::from_str(result.structured_content.get())?;
337        assert!(result.is_error);
338        assert_eq!(refusal.code, code);
339        let TextContent::Text { text } = &result.content[0];
340        assert_eq!(text, result.structured_content.get());
341        let encoded = serde_json::to_string(result)?;
342        assert!(!encoded.contains("\"jsonrpc\""));
343        assert!(!encoded.contains("\"error\""));
344        let wire: serde_json::Value = serde_json::to_value(result)?;
345        assert_eq!(wire["isError"], true);
346        assert_eq!(
347            wire["structuredContent"],
348            serde_json::from_str::<serde_json::Value>(result.structured_content.get())?
349        );
350        let text = wire["content"][0]["text"]
351            .as_str()
352            .ok_or("refusal text missing")?;
353        let refusal: ServiceRefusal = serde_json::from_str(text)?;
354        assert_eq!(refusal.code, code);
355        Ok(())
356    }
357
358    #[test]
359    fn query_sends_one_request_with_exact_inputs() -> TestResult {
360        let mut sender = RecordingSender::answered(ANSWER)?;
361        dispatch(&mut sender, &call("run_query", ARGUMENTS)?)?;
362        assert_eq!(sender.requests.len(), 1);
363        let Some(protocol::Operation::QueryRun(request)) = sender.requests.first() else {
364            return Err(std::io::Error::other("expected query.run").into());
365        };
366        assert_eq!(request.selection.branch, "main");
367        assert_eq!(
368            request.selection.version,
369            Some(protocol::Version {
370                generation: 7,
371                sequence: 11
372            })
373        );
374        assert_eq!(request.query, ".amount");
375        assert_eq!(
376            request.parameters.get("amount").map(|raw| raw.get()),
377            Some("1e2")
378        );
379        assert_eq!(
380            request.parameters.get("identity").map(|raw| raw.get()),
381            Some("123456789012345678")
382        );
383        assert_eq!(request.page.rows.get(), 17);
384        assert_eq!(request.page.bytes.get(), 4096);
385        assert_eq!(request.continuation.as_deref(), Some("opaque+/=cursor"));
386        assert_eq!(serde_json::to_string(&request.rows)?, "\"values\"");
387        Ok(())
388    }
389
390    #[test]
391    fn raw_row_numbers_survive_both_result_representations() -> TestResult {
392        #[derive(serde::Deserialize)]
393        struct EncodedResult {
394            #[serde(rename = "structuredContent")]
395            structured_content: Box<RawValue>,
396        }
397
398        let mut sender = RecordingSender::answered(ANSWER)?;
399        let result = dispatch(&mut sender, &call("run_query", ARGUMENTS)?)?;
400        assert!(!result.is_error);
401        assert_eq!(result.content.len(), 1);
402        let TextContent::Text { text } = &result.content[0];
403        assert_eq!(text, result.structured_content.get());
404        assert!(text.contains(r#""amount":1e2"#));
405        assert!(text.contains(r#""identity":123456789012345678"#));
406        let output: types::RunQueryOutput = serde_json::from_str(result.structured_content.get())?;
407        assert_eq!(output.selection.branch, "main");
408        assert_eq!(
409            output.selection.version.0,
410            protocol::Version {
411                generation: 7,
412                sequence: 11
413            }
414        );
415        assert_eq!(output.rows.next_after.as_deref(), Some("next+/=cursor"));
416        let row = output
417            .rows
418            .entries
419            .first()
420            .ok_or_else(|| std::io::Error::other("expected one row"))?;
421        assert_eq!(
422            row.value.get(),
423            r#"{"amount":1e2,"identity":123456789012345678}"#
424        );
425        let origin = row
426            .origin
427            .as_ref()
428            .ok_or_else(|| std::io::Error::other("expected origin"))?;
429        assert_eq!(origin.collection, "accounts");
430        assert_eq!(origin.document_id, "a");
431        let encoded: EncodedResult = serde_json::from_str(&serde_json::to_string(&result)?)?;
432        assert_eq!(
433            encoded.structured_content.get(),
434            result.structured_content.get()
435        );
436        Ok(())
437    }
438
439    #[test]
440    fn fresh_capture_and_scalar_rows_are_preserved() -> TestResult {
441        let mut sender = RecordingSender::answered(
442            r#"{"selection":{"branch":"main","version":{"generation":"8","sequence":"12"}},"rows":[{"value":1e2,"origin":null}],"next":null}"#,
443        )?;
444        let args = ARGUMENTS.replace(r#"{"generation":"7","sequence":"11"}"#, "null");
445        let result = dispatch(&mut sender, &call("run_query", &args)?)?;
446        let Some(protocol::Operation::QueryRun(request)) = sender.requests.first() else {
447            return Err(std::io::Error::other("expected query.run").into());
448        };
449        assert_eq!(request.selection.version, None);
450        let output: types::RunQueryOutput = serde_json::from_str(result.structured_content.get())?;
451        assert_eq!(
452            output.selection.version.0,
453            protocol::Version {
454                generation: 8,
455                sequence: 12
456            }
457        );
458        assert_eq!(
459            output.rows.entries.first().map(|row| row.value.get()),
460            Some("1e2")
461        );
462        assert!(
463            output
464                .rows
465                .entries
466                .first()
467                .is_some_and(|row| row.origin.is_none())
468        );
469        assert_eq!(output.rows.next_after, None);
470        Ok(())
471    }
472
473    #[test]
474    fn service_refusals_keep_their_codes_and_messages() -> TestResult {
475        for code in ["collection_missing", "version_conflict"] {
476            let mut sender = RecordingSender {
477                requests: Vec::new(),
478                answer: Some(Err(ServiceRefusal {
479                    code: code.into(),
480                    message: "original refusal".into(),
481                    receipt: None,
482                })),
483            };
484            let result = dispatch(&mut sender, &call("run_query", ARGUMENTS)?)?;
485            assert_refusal(&result, code)?;
486            let refusal: ServiceRefusal = serde_json::from_str(result.structured_content.get())?;
487            assert_eq!(refusal.message, "original refusal");
488            assert_eq!(sender.requests.len(), 1);
489            let encoded = serde_json::to_string(&result)?;
490            assert!(!encoded.contains("\"jsonrpc\""));
491            assert!(!encoded.contains("\"error\""));
492        }
493        Ok(())
494    }
495
496    #[test]
497    fn unknown_argument_member_makes_no_request() -> TestResult {
498        let mut sender = RecordingSender::answered(ANSWER)?;
499        let args = ARGUMENTS.replacen('{', r#"{"caller":{},"#, 1);
500        let result = dispatch(&mut sender, &call("run_query", &args)?)?;
501        assert_refusal(&result, "mcp_arguments_invalid")?;
502        assert!(sender.requests.is_empty());
503        Ok(())
504    }
505
506    #[test]
507    fn invalid_limits_and_nested_members_make_no_request() -> TestResult {
508        for args in [
509            ARGUMENTS.replace("\"rows\":17", "\"rows\":0"),
510            ARGUMENTS.replace("\"bytes\":4096", "\"bytes\":0"),
511            ARGUMENTS.replace("\"rows\":17", "\"rows\":17,\"extra\":true"),
512            "{}".into(),
513            "[]".into(),
514        ] {
515            let mut sender = RecordingSender::answered(ANSWER)?;
516            let result = dispatch(&mut sender, &call("run_query", &args)?)?;
517            assert_refusal(&result, "mcp_arguments_invalid")?;
518            assert!(sender.requests.is_empty());
519        }
520        Ok(())
521    }
522
523    #[test]
524    fn merge_branch_checks_its_arguments_before_any_request() -> TestResult {
525        let mut sender = RecordingSender::answered(ANSWER)?;
526        let result = dispatch(&mut sender, &call("merge_branch", "{}")?)?;
527        assert_refusal(&result, "mcp_arguments_invalid")?;
528        assert!(sender.requests.is_empty());
529        Ok(())
530    }
531
532    #[test]
533    fn malformed_write_receipts_leave_the_mutation_outcome_unknown() -> TestResult {
534        let arguments = r#"{"request_id":"new","branch":"main","expected":{"generation":"7","sequence":"11"},"collection":"records","schema":null}"#;
535        for answer in [
536            "{}",
537            r#"{"branch":"main","version":{"generation":"7","sequence":"12"},"request_id":"different"}"#,
538            r#"{"branch":"other","version":{"generation":"7","sequence":"12"},"request_id":"new"}"#,
539        ] {
540            let mut sender = RecordingSender::answered(answer)?;
541            let result = dispatch(&mut sender, &call("define_collection", arguments)?)?;
542            assert_refusal(&result, "mcp_client_outcome_unknown")?;
543            let refusal: ServiceRefusal = serde_json::from_str(result.structured_content.get())?;
544            assert!(refusal.message.contains("mcp_service_answer_invalid"));
545            assert!(refusal.message.contains("request_id new"));
546            assert!(refusal.message.contains("mutation.receipt"));
547            assert_eq!(sender.requests.len(), 1);
548        }
549        Ok(())
550    }
551
552    #[test]
553    fn adapted_tools_reject_missing_input_before_a_service_request() -> TestResult {
554        for tool in crate::mcp::schema::tools() {
555            let mut sender = RecordingSender::answered(ANSWER)?;
556            let result = dispatch(&mut sender, &call(tool.name, "{}")?)?;
557            assert_refusal(&result, "mcp_arguments_invalid")?;
558            assert!(sender.requests.is_empty());
559        }
560        Ok(())
561    }
562
563    #[test]
564    fn malformed_service_answers_are_named_tool_refusals() -> TestResult {
565        for answer in [
566            "{}",
567            r#"{"selection":{"branch":"main","version":{"generation":"7","sequence":"11"}},"rows":[{"origin":null}],"next":null}"#,
568            r#"{"selection":{"branch":"main","version":{"generation":"7","sequence":"11"}},"rows":[{"value":1}] ,"next":null}"#,
569            r#"{"selection":{"branch":"main","version":{"generation":"7","sequence":"11"}},"rows":[]}"#,
570            r#"{"selection":{"branch":"main","version":{"generation":"7","sequence":"11"}},"rows":[],"next":42}"#,
571        ] {
572            let mut sender = RecordingSender::answered(answer)?;
573            let result = dispatch(&mut sender, &call("run_query", ARGUMENTS)?)?;
574            assert_refusal(&result, "mcp_service_answer_invalid")?;
575            assert_eq!(sender.requests.len(), 1);
576        }
577        Ok(())
578    }
579
580    #[test]
581    fn read_tools_use_after_and_pages_with_entries_and_next_after() -> TestResult {
582        for tool in crate::mcp::schema::tools() {
583            if [
584                "describe_store",
585                "run_query",
586                "read_history",
587                "review_branch",
588            ]
589            .contains(&tool.name)
590            {
591                let properties = tool.input_schema.as_value()["properties"]
592                    .as_object()
593                    .ok_or("input properties missing")?;
594                assert!(
595                    properties.contains_key("after"),
596                    "{} has no after field",
597                    tool.name
598                );
599                assert!(
600                    !properties.contains_key("cursors"),
601                    "{} still has cursors",
602                    tool.name
603                );
604                assert!(
605                    !properties.contains_key("continuation"),
606                    "{} still has continuation",
607                    tool.name
608                );
609            }
610        }
611        let mut arguments: serde_json::Value = serde_json::from_str(ARGUMENTS)?;
612        let arguments = arguments
613            .as_object_mut()
614            .ok_or("arguments are not an object")?;
615        arguments.remove("continuation");
616        arguments.insert("after".to_owned(), serde_json::json!("opaque+/=cursor"));
617        let mut sender = RecordingSender::answered(ANSWER)?;
618        let result = dispatch(
619            &mut sender,
620            &call("run_query", &serde_json::to_string(arguments)?)?,
621        )?;
622        assert!(!result.is_error, "{}", result.structured_content.get());
623        assert_eq!(sender.requests.len(), 1);
624        let Some(protocol::Operation::QueryRun(request)) = sender.requests.first() else {
625            return Err(std::io::Error::other("expected query.run").into());
626        };
627        assert_eq!(request.continuation.as_deref(), Some("opaque+/=cursor"));
628        let output: serde_json::Value = serde_json::from_str(result.structured_content.get())?;
629        assert_eq!(
630            output["rows"]["entries"]
631                .as_array()
632                .ok_or("entries missing")?
633                .len(),
634            1
635        );
636        assert_eq!(output["rows"]["next_after"], "next+/=cursor");
637        assert!(output.get("next").is_none());
638        Ok(())
639    }
640
641    #[test]
642    fn query_and_fresh_description_refuse_unrequested_snapshots_before_more_calls() -> TestResult {
643        for (answer, arguments) in [
644            (
645                ANSWER.replace("\"branch\":\"main\"", "\"branch\":\"other\""),
646                ARGUMENTS.to_owned(),
647            ),
648            (
649                ANSWER.replace("\"sequence\":\"11\"", "\"sequence\":\"12\""),
650                ARGUMENTS.to_owned(),
651            ),
652            (
653                ANSWER.replace("\"branch\":\"main\"", "\"branch\":\"other\""),
654                ARGUMENTS.replace(r#"{"generation":"7","sequence":"11"}"#, "null"),
655            ),
656        ] {
657            let mut sender = RecordingSender::answered(&answer)?;
658            let result = dispatch(&mut sender, &call("run_query", &arguments)?)?;
659            assert_refusal(&result, "mcp_service_snapshot_mismatch")?;
660            assert_eq!(sender.requests.len(), 1);
661        }
662        let mut arguments: serde_json::Value = serde_json::from_str(&types::tests::inputs()[0].1)?;
663        arguments["selection"] = serde_json::json!({"branch":"main","version":null});
664        let mut sender = RecordingSender::answered(
665            r#"{"selection":{"branch":"other","version":{"generation":"7","sequence":"11"}},"entries":[],"next_after":null}"#,
666        )?;
667        let result = dispatch(
668            &mut sender,
669            &call("describe_store", &arguments.to_string())?,
670        )?;
671        assert_refusal(&result, "mcp_service_snapshot_mismatch")?;
672        assert_eq!(sender.requests.len(), 1);
673        Ok(())
674    }
675
676    #[test]
677    fn query_refuses_a_projected_page_that_exceeds_the_requested_bytes() -> TestResult {
678        let answer = r#"{"selection":{"branch":"main","version":{"generation":"7","sequence":"11"}},"rows":[{"value":1,"origin":{"collection":"records","id":"a"}}],"next":null}"#;
679        let mut arguments: serde_json::Value = serde_json::from_str(ARGUMENTS)?;
680        arguments["page"]["bytes"] = serde_json::json!(answer.len());
681        let mut sender = RecordingSender::answered(answer)?;
682        let result = dispatch(&mut sender, &call("run_query", &arguments.to_string())?)?;
683        assert_refusal(&result, "mcp_page_bytes_exceeded")?;
684        assert_eq!(sender.requests.len(), 1);
685        Ok(())
686    }
687
688    #[test]
689    fn diff_and_conflict_values_distinguish_absence_null_and_raw_json() -> TestResult {
690        let difference: review_lines::Difference = serde_json::from_value(serde_json::json!({
691            "collection":"records","document_id":"a","what":"document",
692            "base":"null","here":"\"\""
693        }))?;
694        let difference = review_lines::difference(difference).map_err(|refusal| {
695            std::io::Error::other(format!("{}: {}", refusal.code, refusal.message))
696        })?;
697        let difference = serde_json::to_value(difference)?;
698        assert_eq!(
699            difference["before"],
700            serde_json::json!({"kind":"value","value":null})
701        );
702        assert_eq!(
703            difference["after"],
704            serde_json::json!({"kind":"value","value":""})
705        );
706        let conflict: review_lines::Conflict = serde_json::from_value(serde_json::json!({
707            "collection":"records","document_id":"a","what":"document",
708            "ancestor":"","target":"null","source":r#"{"n":1e2,"id":123456789012345678}"#,
709            "state":"conflict","choice_token":"chosen"
710        }))?;
711        let conflict = review_lines::conflict(conflict).map_err(|refusal| {
712            std::io::Error::other(format!("{}: {}", refusal.code, refusal.message))
713        })?;
714        let encoded = serde_json::to_string(&conflict)?;
715        assert!(encoded.contains(r#""n":1e2"#));
716        assert!(encoded.contains(r#""id":123456789012345678"#));
717        let conflict: serde_json::Value = serde_json::from_str(&encoded)?;
718        assert_eq!(conflict["ancestor"], serde_json::json!({"kind":"absent"}));
719        assert_eq!(
720            conflict["target"],
721            serde_json::json!({"kind":"value","value":null})
722        );
723        assert_eq!(conflict["source"]["kind"], "value");
724        assert_eq!(conflict["state"], "conflict");
725        assert_eq!(conflict["choice_token"], "chosen");
726        let malformed: review_lines::Conflict = serde_json::from_value(serde_json::json!({
727            "collection":"records","document_id":"a","what":"document",
728            "ancestor":"","target":"not-json","source":"null",
729            "state":"conflict","choice_token":"chosen"
730        }))?;
731        let refusal = review_lines::conflict(malformed)
732            .err()
733            .ok_or("invalid JSON accepted")?;
734        assert_eq!(refusal.code, "mcp_service_answer_invalid");
735        Ok(())
736    }
737}
738
739#[cfg(test)]
740mod typed_error_retention_tests {
741    use super::*;
742    #[test]
743    fn is_error_keeps_the_full_structured_error_data() -> Result<(), Box<dyn std::error::Error>> {
744        let refusal = ServiceRefusal {
745            code: "partial_commit".into(),
746            message: "original complete cause".into(),
747            receipt: None,
748        };
749        let result = refused(&refusal)?;
750        let encoded = serde_json::to_value(&result)?;
751        assert_eq!(encoded["isError"], true);
752        assert_eq!(encoded["structuredContent"]["code"], "partial_commit");
753        assert_eq!(
754            encoded["structuredContent"]["message"],
755            "original complete cause"
756        );
757        Ok(())
758    }
759}