Skip to main content

libmcp_testkit/
lib.rs

1//! Shared test helpers for `libmcp` consumers.
2
3use libmcp::{SurfaceKind, ToolProjection};
4use serde::de::DeserializeOwned;
5use serde_json::Value;
6use std::{
7    fs::File,
8    io::{self, BufRead, BufReader},
9    path::Path,
10};
11
12const OPAQUE_ID_FIELD: &str = "id";
13const OPAQUE_ID_SUFFIX: &str = "_id";
14const LIST_BODY_FIELDS: &[&str] = &["body", "payload_preview", "analysis", "rationale"];
15const REFERENCE_BODY_FIELDS: &[&str] = &["body", "content", "text", "bytes"];
16
17/// Reads an append-only JSONL file into typed records.
18pub fn read_json_lines<T>(path: &Path) -> io::Result<Vec<T>>
19where
20    T: DeserializeOwned,
21{
22    let file = File::open(path)?;
23    let reader = BufReader::new(file);
24    let mut records = Vec::new();
25    for line in reader.lines() {
26        let line = line?;
27        if line.trim().is_empty() {
28            continue;
29        }
30        let parsed = serde_json::from_str::<T>(line.as_str()).map_err(|error| {
31            io::Error::new(
32                io::ErrorKind::InvalidData,
33                format!("invalid JSONL test record: {error}"),
34            )
35        })?;
36        records.push(parsed);
37    }
38    Ok(records)
39}
40
41/// Assertion failure for projection doctrine checks.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct ProjectionAssertion {
44    path: String,
45    message: String,
46}
47
48impl ProjectionAssertion {
49    fn new(path: impl Into<String>, message: impl Into<String>) -> Self {
50        Self {
51            path: path.into(),
52            message: message.into(),
53        }
54    }
55}
56
57impl std::fmt::Display for ProjectionAssertion {
58    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        write!(formatter, "{}: {}", self.path, self.message)
60    }
61}
62
63impl std::error::Error for ProjectionAssertion {}
64
65/// Asserts that a projection obeys the doctrine implied by its surface policy.
66pub fn assert_projection_doctrine<T>(projection: &T) -> Result<(), ProjectionAssertion>
67where
68    T: ToolProjection,
69{
70    let concise = projection
71        .concise_projection()
72        .map_err(|error| ProjectionAssertion::new("$concise", error.to_string()))?;
73    let full = projection
74        .full_projection()
75        .map_err(|error| ProjectionAssertion::new("$full", error.to_string()))?;
76    let policy = projection.projection_policy();
77    if policy.forbid_opaque_ids {
78        assert_no_opaque_ids(&concise)?;
79        assert_no_opaque_ids(&full)?;
80    }
81    if policy.reference_only {
82        assert_reference_only(&concise)?;
83        assert_reference_only(&full)?;
84    }
85    if matches!(policy.kind, SurfaceKind::List) {
86        assert_list_shape(&concise)?;
87        assert_list_shape(&full)?;
88    }
89    Ok(())
90}
91
92/// Asserts that a JSON value does not leak opaque database identifiers.
93pub fn assert_no_opaque_ids(value: &Value) -> Result<(), ProjectionAssertion> {
94    walk(value, "$", &mut |path, value| {
95        if let Value::Object(object) = value {
96            for key in object.keys() {
97                if is_opaque_identifier_field(key) {
98                    return Err(ProjectionAssertion::new(
99                        format!("{path}.{key}"),
100                        "opaque identifier field leaked into model-facing projection",
101                    ));
102                }
103            }
104        }
105        Ok(())
106    })
107}
108
109/// Asserts that a list-like surface does not inline prose bodies.
110pub fn assert_list_shape(value: &Value) -> Result<(), ProjectionAssertion> {
111    walk(value, "$", &mut |path, value| {
112        if let Value::Object(object) = value {
113            for key in object.keys() {
114                if LIST_BODY_FIELDS.contains(&key.as_str()) {
115                    return Err(ProjectionAssertion::new(
116                        format!("{path}.{key}"),
117                        "list surface leaked body-like content",
118                    ));
119                }
120            }
121        }
122        Ok(())
123    })
124}
125
126/// Asserts that a reference-only surface does not inline large textual content.
127pub fn assert_reference_only(value: &Value) -> Result<(), ProjectionAssertion> {
128    walk(value, "$", &mut |path, value| match value {
129        Value::Object(object) => {
130            for key in object.keys() {
131                if REFERENCE_BODY_FIELDS.contains(&key.as_str()) {
132                    return Err(ProjectionAssertion::new(
133                        format!("{path}.{key}"),
134                        "reference-only surface inlined artifact content",
135                    ));
136                }
137            }
138            Ok(())
139        }
140        _ => Ok(()),
141    })
142}
143
144fn walk(
145    value: &Value,
146    path: &str,
147    visitor: &mut impl FnMut(&str, &Value) -> Result<(), ProjectionAssertion>,
148) -> Result<(), ProjectionAssertion> {
149    visitor(path, value)?;
150    match value {
151        Value::Array(items) => {
152            for (index, item) in items.iter().enumerate() {
153                walk(item, format!("{path}[{index}]").as_str(), visitor)?;
154            }
155        }
156        Value::Object(object) => {
157            for (key, child) in object {
158                walk(child, format!("{path}.{key}").as_str(), visitor)?;
159            }
160        }
161        Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
162    }
163    Ok(())
164}
165
166fn is_opaque_identifier_field(key: &str) -> bool {
167    key == OPAQUE_ID_FIELD || key.ends_with(OPAQUE_ID_SUFFIX)
168}