diff options
| author | main <main@swarm.moe> | 2026-03-20 20:14:50 -0400 |
|---|---|---|
| committer | main <main@swarm.moe> | 2026-03-20 20:14:50 -0400 |
| commit | bb92a05eb5446e07c6288e266bd06d7b5899eee5 (patch) | |
| tree | 56846c754fdf7c89c4e090d18d8269a2ce9f53f7 /crates/libmcp-testkit | |
| parent | 84e898d9ba699451d5d13fe384e7bbe220564bc1 (diff) | |
| download | libmcp-bb92a05eb5446e07c6288e266bd06d7b5899eee5.zip | |
Add projection traits and doctrine testkit
Diffstat (limited to 'crates/libmcp-testkit')
| -rw-r--r-- | crates/libmcp-testkit/Cargo.toml | 1 | ||||
| -rw-r--r-- | crates/libmcp-testkit/src/lib.rs | 130 |
2 files changed, 131 insertions, 0 deletions
diff --git a/crates/libmcp-testkit/Cargo.toml b/crates/libmcp-testkit/Cargo.toml index b8e68b1..a958650 100644 --- a/crates/libmcp-testkit/Cargo.toml +++ b/crates/libmcp-testkit/Cargo.toml @@ -8,6 +8,7 @@ description.workspace = true readme.workspace = true [dependencies] +libmcp = { path = "../libmcp" } serde.workspace = true serde_json.workspace = true 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(()) +} |