1use 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
17pub 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#[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
65pub 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
92pub 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
109pub 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
126pub 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}