swarm repositories / source
aboutsummaryrefslogtreecommitdiff
path: root/crates/libmcp-testkit/src
diff options
context:
space:
mode:
authormain <main@swarm.moe>2026-03-20 20:14:50 -0400
committermain <main@swarm.moe>2026-03-20 20:14:50 -0400
commitbb92a05eb5446e07c6288e266bd06d7b5899eee5 (patch)
tree56846c754fdf7c89c4e090d18d8269a2ce9f53f7 /crates/libmcp-testkit/src
parent84e898d9ba699451d5d13fe384e7bbe220564bc1 (diff)
downloadlibmcp-bb92a05eb5446e07c6288e266bd06d7b5899eee5.zip
Add projection traits and doctrine testkit
Diffstat (limited to 'crates/libmcp-testkit/src')
-rw-r--r--crates/libmcp-testkit/src/lib.rs130
1 files changed, 130 insertions, 0 deletions
diff --git a/crates/libmcp-testkit/src/lib.rs b/crates/libmcp-testkit/src/lib.rs
index 9f33643..3b61bd5 100644
--- a/crates/libmcp-testkit/src/lib.rs
+++ b/crates/libmcp-testkit/src/lib.rs
@@ -1,6 +1,8 @@
//! Shared test helpers for `libmcp` consumers.
+use libmcp::{SurfaceKind, ToolProjection};
use serde::de::DeserializeOwned;
+use serde_json::Value;
use std::{
fs::File,
io::{self, BufRead, BufReader},
@@ -30,3 +32,131 @@ where
}
Ok(records)
}
+
+/// Assertion failure for projection doctrine checks.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ProjectionAssertion {
+ path: String,
+ message: String,
+}
+
+impl ProjectionAssertion {
+ fn new(path: impl Into<String>, message: impl Into<String>) -> Self {
+ Self {
+ path: path.into(),
+ message: message.into(),
+ }
+ }
+}
+
+impl std::fmt::Display for ProjectionAssertion {
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(formatter, "{}: {}", self.path, self.message)
+ }
+}
+
+impl std::error::Error for ProjectionAssertion {}
+
+/// Asserts that a projection obeys the doctrine implied by its surface policy.
+pub fn assert_projection_doctrine<T>(projection: &T) -> Result<(), ProjectionAssertion>
+where
+ T: ToolProjection,
+{
+ let concise = projection
+ .concise_projection()
+ .map_err(|error| ProjectionAssertion::new("$concise", error.to_string()))?;
+ let full = projection
+ .full_projection()
+ .map_err(|error| ProjectionAssertion::new("$full", error.to_string()))?;
+ let policy = projection.projection_policy();
+ if policy.forbid_opaque_ids {
+ assert_no_opaque_ids(&concise)?;
+ assert_no_opaque_ids(&full)?;
+ }
+ if policy.reference_only {
+ assert_reference_only(&concise)?;
+ assert_reference_only(&full)?;
+ }
+ if matches!(policy.kind, SurfaceKind::List) {
+ assert_list_shape(&concise)?;
+ assert_list_shape(&full)?;
+ }
+ Ok(())
+}
+
+/// Asserts that a JSON value does not leak opaque database identifiers.
+pub fn assert_no_opaque_ids(value: &Value) -> Result<(), ProjectionAssertion> {
+ walk(value, "$", &mut |path, value| {
+ if let Value::Object(object) = value {
+ for key in object.keys() {
+ if key == "id" || key.ends_with("_id") {
+ return Err(ProjectionAssertion::new(
+ format!("{path}.{key}"),
+ "opaque identifier field leaked into model-facing projection",
+ ));
+ }
+ }
+ }
+ Ok(())
+ })
+}
+
+/// Asserts that a list-like surface does not inline prose bodies.
+pub fn assert_list_shape(value: &Value) -> Result<(), ProjectionAssertion> {
+ walk(value, "$", &mut |path, value| {
+ if let Value::Object(object) = value {
+ for key in object.keys() {
+ if matches!(
+ key.as_str(),
+ "body" | "payload_preview" | "analysis" | "rationale"
+ ) {
+ return Err(ProjectionAssertion::new(
+ format!("{path}.{key}"),
+ "list surface leaked body-like content",
+ ));
+ }
+ }
+ }
+ Ok(())
+ })
+}
+
+/// Asserts that a reference-only surface does not inline large textual content.
+pub fn assert_reference_only(value: &Value) -> Result<(), ProjectionAssertion> {
+ walk(value, "$", &mut |path, value| match value {
+ Value::Object(object) => {
+ for key in object.keys() {
+ if matches!(key.as_str(), "body" | "content" | "text" | "bytes") {
+ return Err(ProjectionAssertion::new(
+ format!("{path}.{key}"),
+ "reference-only surface inlined artifact content",
+ ));
+ }
+ }
+ Ok(())
+ }
+ _ => Ok(()),
+ })
+}
+
+fn walk(
+ value: &Value,
+ path: &str,
+ visitor: &mut impl FnMut(&str, &Value) -> Result<(), ProjectionAssertion>,
+) -> Result<(), ProjectionAssertion> {
+ visitor(path, value)?;
+ match value {
+ Value::Array(items) => {
+ for (index, item) in items.iter().enumerate() {
+ walk(item, format!("{path}[{index}]").as_str(), visitor)?;
+ }
+ }
+ Value::Object(object) => {
+ for (key, child) in object {
+ walk(child, format!("{path}.{key}").as_str(), visitor)?;
+ }
+ }
+ Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
+ }
+ Ok(())
+}