use rmcp::{
ServiceExt,
model::{CallToolRequestParams, ErrorCode},
};
use serde_json::{Value, json};
use std::{fs, time::SystemTime};
use crate::{ChromaFrameMcpServer, schema};
use chromaframe_sdk::{
Point, RegionKind, RegionMask, RegionObservation, RegionSource, RegionStatus,
};
async fn start_server() -> (
rmcp::service::RunningService<rmcp::RoleClient, ()>,
tokio::task::JoinHandle<()>,
) {
let (server_transport, client_transport) = tokio::io::duplex(64 * 1024);
let server = ChromaFrameMcpServer::from_env();
let handle = tokio::spawn(async move {
let running = server
.serve(server_transport)
.await
.expect("server should start");
let _ = running.waiting().await.expect("server should stop cleanly");
});
let client = ().serve(client_transport).await.expect("client should connect");
(client, handle)
}
fn json_object(value: Value) -> serde_json::Map<String, Value> {
value
.as_object()
.cloned()
.expect("arguments should be a JSON object")
}
fn schema_property<'a>(schema: &'a Value, field: &str) -> &'a Value {
schema
.get("properties")
.and_then(Value::as_object)
.and_then(|properties| properties.get(field))
.unwrap_or_else(|| panic!("schema should expose `{field}` property"))
}
fn resolve_local_schema_ref<'a>(root: &'a Value, mut schema: &'a Value) -> &'a Value {
loop {
let Some(reference) = schema.get("$ref").and_then(Value::as_str) else {
return schema;
};
let pointer = reference
.strip_prefix('#')
.expect("schema references should be local JSON pointers");
schema = root
.pointer(pointer)
.unwrap_or_else(|| panic!("schema ref `{reference}` should resolve"));
}
}
fn assert_goal_vector_schema_bounds(root_schema: &Value, goal_vector_schema: &Value) {
let goal_vector_schema = resolve_local_schema_ref(root_schema, goal_vector_schema);
assert_eq!(
goal_vector_schema.get("additionalProperties"),
Some(&json!(false))
);
for field in [
"contrast_target",
"chroma_target",
"feature_readability_target",
"artificiality_tolerance",
] {
let field_schema = schema_property(goal_vector_schema, field);
assert_eq!(field_schema.get("minimum"), Some(&json!(0.0)), "{field}");
assert_eq!(field_schema.get("maximum"), Some(&json!(1.0)), "{field}");
}
let warmth_schema = schema_property(goal_vector_schema, "warmth_target");
assert_eq!(warmth_schema.get("minimum"), Some(&json!(-1.0)));
assert_eq!(warmth_schema.get("maximum"), Some(&json!(1.0)));
}
fn sorted_property_names(schema: &Value) -> Vec<String> {
let mut names = schema
.get("properties")
.and_then(Value::as_object)
.expect("schema should expose properties")
.keys()
.cloned()
.collect::<Vec<_>>();
names.sort();
names
}
fn assert_tool_error_text_does_not_contain(error_text: &str, forbidden: &str) {
assert!(
!error_text.contains(forbidden),
"tool error must not leak private value `{forbidden}`; got `{error_text}`"
);
}
#[test]
fn low_evidence_beard_region_is_exposed_without_overclaiming() {
let summary = crate::runtime::region_summary(&RegionObservation {
kind: RegionKind::Beard,
status: RegionStatus::LowEvidence,
source: RegionSource::Approximation,
confidence: 0.35,
mask: Some(RegionMask::Polygon {
polygons: vec![vec![
Point { x: 0.0, y: 0.0 },
Point { x: 1.0, y: 0.0 },
Point { x: 1.0, y: 1.0 },
]],
}),
sample_hint: Some(24),
approximate_reason: Some("clean_shaven_or_low_stubble_evidence".to_string()),
not_measured_reason: None,
});
assert_eq!(summary.kind, "Beard");
assert_eq!(summary.status, "low_evidence");
assert_eq!(summary.source, "Approximation");
assert_eq!(summary.confidence, 0.35);
assert_eq!(
summary.reason.as_deref(),
Some("clean_shaven_or_low_stubble_evidence")
);
}
#[tokio::test]
async fn tool_listing_has_three_read_only_idempotent_tools() {
let (client, handle) = start_server().await;
let tools = client
.peer()
.list_all_tools()
.await
.expect("tools list should succeed");
let mut names = tools
.iter()
.map(|tool| tool.name.to_string())
.collect::<Vec<_>>();
names.sort();
assert_eq!(
names,
vec![
"chromaframe_analyze_image",
"chromaframe_rank_candidates",
"chromaframe_readiness"
]
);
for tool in tools {
let annotations = tool.annotations.expect("tool annotations are required");
assert_eq!(annotations.read_only_hint, Some(true));
assert_eq!(annotations.idempotent_hint, Some(true));
}
client
.cancel()
.await
.expect("client shutdown should succeed");
handle.await.expect("server join should succeed");
}
#[tokio::test]
async fn analyze_schema_is_read_only_and_excludes_overlay_fields() {
let (client, handle) = start_server().await;
let tools = client
.peer()
.list_all_tools()
.await
.expect("tools list should succeed");
let analyze_tool = tools
.iter()
.find(|tool| tool.name == "chromaframe_analyze_image")
.expect("analyze tool should exist");
let annotations = analyze_tool
.annotations
.as_ref()
.expect("analyze annotations should exist");
assert_eq!(annotations.read_only_hint, Some(true));
assert_eq!(annotations.idempotent_hint, Some(true));
let input_schema = Value::Object(analyze_tool.input_schema.as_ref().clone());
assert_eq!(
input_schema.get("additionalProperties"),
Some(&json!(false))
);
assert_eq!(
sorted_property_names(&input_schema),
vec!["candidates", "goal_vector", "image_path", "limit"]
);
assert_eq!(
schema_property(&input_schema, "image_path").get("maxLength"),
Some(&json!(schema::MAX_PATH_BYTES))
);
assert_eq!(
schema_property(&input_schema, "limit").get("maximum"),
Some(&json!(schema::MAX_RANKING_LIMIT))
);
assert_eq!(
schema_property(&input_schema, "candidates").get("maxItems"),
Some(&json!(schema::MAX_CANDIDATE_COUNT))
);
client
.cancel()
.await
.expect("client shutdown should succeed");
handle.await.expect("server join should succeed");
}
#[tokio::test]
async fn goal_vector_schemas_publish_sdk_bounds() {
let (client, handle) = start_server().await;
let tools = client
.peer()
.list_all_tools()
.await
.expect("tools list should succeed");
for tool_name in ["chromaframe_analyze_image", "chromaframe_rank_candidates"] {
let tool = tools
.iter()
.find(|tool| tool.name == tool_name)
.unwrap_or_else(|| panic!("{tool_name} tool should exist"));
let input_schema = Value::Object(tool.input_schema.as_ref().clone());
let goal_vector_schema = schema_property(&input_schema, "goal_vector");
assert_goal_vector_schema_bounds(&input_schema, goal_vector_schema);
}
client
.cancel()
.await
.expect("client shutdown should succeed");
handle.await.expect("server join should succeed");
}
#[tokio::test]
async fn manual_ranking_tool_returns_deterministic_top_candidate() {
let (client, handle) = start_server().await;
let result = client
.call_tool(
CallToolRequestParams::new("chromaframe_rank_candidates").with_arguments(json_object(
json!({
"skin_lab": {"l": 72.0, "a": 13.0, "b": 16.0},
"brow_lab": {"l": 36.0, "a": 8.0, "b": 11.0},
"confidence": 0.65,
"limit": 2,
"candidates": [
{"name": "soft black", "srgb": [28, 25, 23]},
{"name": "medium brown", "srgb": [92, 66, 45]}
]
}),
)),
)
.await
.expect("manual ranking should succeed");
assert_eq!(result.is_error, Some(false));
let output: crate::ManualRankOutput = result
.into_typed()
.expect("manual rank output should deserialize");
assert_eq!(output.status, "complete");
assert_eq!(output.rankings.len(), 2);
assert!(output.rankings[0].score >= output.rankings[1].score);
client
.cancel()
.await
.expect("client shutdown should succeed");
handle.await.expect("server join should succeed");
}
#[tokio::test]
async fn manual_ranking_rejects_goal_vector_out_of_range() {
let (client, handle) = start_server().await;
let result = client
.call_tool(
CallToolRequestParams::new("chromaframe_rank_candidates").with_arguments(json_object(
json!({
"skin_lab": {"l": 72.0, "a": 13.0, "b": 16.0},
"goal_vector": {"contrast_target": 1.5},
"candidates": [{"name": "soft black", "srgb": [28, 25, 23]}]
}),
)),
)
.await
.expect("tool call should return a sanitized tool error result");
assert_eq!(result.is_error, Some(true));
client
.cancel()
.await
.expect("client shutdown should succeed");
handle.await.expect("server join should succeed");
}
#[tokio::test]
async fn invalid_blank_candidate_name_is_rejected_at_boundary() {
let (client, handle) = start_server().await;
let error = client
.call_tool(
CallToolRequestParams::new("chromaframe_rank_candidates").with_arguments(json_object(
json!({
"skin_lab": {"l": 72.0, "a": 13.0, "b": 16.0},
"candidates": [{"name": " ", "srgb": [28, 25, 23]}]
}),
)),
)
.await
.expect_err("invalid params should fail before tool execution");
match error {
rmcp::ServiceError::McpError(data) => assert_eq!(data.code, ErrorCode::INVALID_PARAMS),
unexpected => panic!("expected invalid params, got {unexpected:?}"),
}
client
.cancel()
.await
.expect("client shutdown should succeed");
handle.await.expect("server join should succeed");
}
#[tokio::test]
async fn candidate_name_and_count_bounds_are_enforced() {
let too_long_name = "x".repeat(schema::MAX_CANDIDATE_NAME_BYTES + 1);
let name_error = serde_json::from_value::<schema::CandidateInput>(json!({
"name": too_long_name,
"srgb": [1, 2, 3]
}))
.expect_err("long candidate names should fail at parse boundary");
assert!(name_error.to_string().contains("candidate `name` exceeds"));
let too_many_candidates = vec![
schema::CandidateInput {
name: "candidate".to_string(),
srgb: [1, 2, 3],
};
schema::MAX_CANDIDATE_COUNT + 1
];
let count_error = schema::validate_candidate_count(too_many_candidates.len())
.expect_err("too many candidates should fail validation");
assert!(count_error.contains("candidate count must be between"));
}
#[tokio::test]
async fn analyze_rejects_unknown_overlay_fields_before_tool_execution() {
let (client, handle) = start_server().await;
let error = client
.call_tool(
CallToolRequestParams::new("chromaframe_analyze_image").with_arguments(json_object(
json!({
"image_path": "/tmp/not-used.png",
"overlay_output_path": "/tmp/overlay.png",
"candidates": [{"name": "candidate", "srgb": [28, 25, 23]}]
}),
)),
)
.await
.expect_err("unknown overlay field should fail as invalid params");
match error {
rmcp::ServiceError::McpError(data) => assert_eq!(data.code, ErrorCode::INVALID_PARAMS),
unexpected => panic!("expected invalid params, got {unexpected:?}"),
}
client
.cancel()
.await
.expect("client shutdown should succeed");
handle.await.expect("server join should succeed");
}
#[tokio::test]
async fn image_read_error_is_sanitized() {
let (client, handle) = start_server().await;
let private_path = "/tmp/chromaframe-private-input-does-not-exist-secret-token.png";
let result = client
.call_tool(
CallToolRequestParams::new("chromaframe_analyze_image").with_arguments(json_object(
json!({
"image_path": private_path,
"candidates": [{"name": "candidate", "srgb": [28, 25, 23]}]
}),
)),
)
.await
.expect("tool call should return a sanitized tool error result");
assert_eq!(result.is_error, Some(true));
let error_text = result
.content
.first()
.and_then(|content| content.as_text())
.map(|content| content.text.as_str())
.expect("tool error should contain text");
assert_tool_error_text_does_not_contain(error_text, private_path);
assert_tool_error_text_does_not_contain(error_text, "secret-token");
client
.cancel()
.await
.expect("client shutdown should succeed");
handle.await.expect("server join should succeed");
}
#[tokio::test]
async fn oversized_encoded_image_is_rejected_before_read_with_sanitized_error() {
let (client, handle) = start_server().await;
let unique = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("system time should be after epoch")
.as_nanos();
let private_path =
std::env::temp_dir().join(format!("chromaframe-private-oversized-secret-{unique}.png"));
let file = fs::File::create(&private_path).expect("temp file should be created");
file.set_len(schema::MAX_ENCODED_IMAGE_BYTES + 1)
.expect("temp file length should be set");
let result = client
.call_tool(
CallToolRequestParams::new("chromaframe_analyze_image").with_arguments(json_object(
json!({
"image_path": private_path.to_string_lossy(),
"candidates": [{"name": "candidate", "srgb": [28, 25, 23]}]
}),
)),
)
.await
.expect("tool call should return a sanitized tool error result");
assert_eq!(result.is_error, Some(true));
let error_text = result
.content
.first()
.and_then(|content| content.as_text())
.map(|content| content.text.as_str())
.expect("tool error should contain text");
assert!(error_text.contains("encoded image exceeds"));
assert_tool_error_text_does_not_contain(error_text, private_path.to_string_lossy().as_ref());
assert_tool_error_text_does_not_contain(error_text, "secret");
fs::remove_file(&private_path).expect("temp file should be removed");
client
.cancel()
.await
.expect("client shutdown should succeed");
handle.await.expect("server join should succeed");
}