diff --git a/lib/crates/fabro-cli/tests/it/cmd/mcp.rs b/lib/crates/fabro-cli/tests/it/cmd/mcp.rs index 309141fb2..8001035f3 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/mcp.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/mcp.rs @@ -426,12 +426,33 @@ async fn stdio_server_initializes_and_lists_run_tools() { "fabro_run_interact", "fabro_run_search", ]); - for (_, _, schema) in tools { + for (name, _, schema) in &tools { assert!( schema.is_object(), "tool should have input schema: {schema}" ); + let properties = schema + .get("properties") + .and_then(serde_json::Value::as_object) + .expect("tool input schema should have properties"); + for (property, property_schema) in properties { + assert!( + property_schema.is_object(), + "{name}.{property} should use an object JSON Schema, got {property_schema}" + ); + } } + let interact_schema = tools + .iter() + .find(|(name, _, _)| name == "fabro_run_interact") + .map(|(_, _, schema)| schema) + .expect("fabro_run_interact tool should be listed"); + assert!( + interact_schema + .pointer("/properties/answer") + .is_some_and(serde_json::Value::is_object), + "fabro_run_interact.answer should have an object JSON Schema: {interact_schema}" + ); client .shutdown() .await diff --git a/lib/crates/fabro-mcp-server/src/run_tools/interact.rs b/lib/crates/fabro-mcp-server/src/run_tools/interact.rs index 2ce94f8ef..abd6b6cf7 100644 --- a/lib/crates/fabro-mcp-server/src/run_tools/interact.rs +++ b/lib/crates/fabro-mcp-server/src/run_tools/interact.rs @@ -1,9 +1,10 @@ +use std::borrow::Cow; use std::sync::Arc; use fabro_api::types; use fabro_client::Client; use fabro_types::RunId; -use schemars::JsonSchema; +use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -30,7 +31,70 @@ pub(crate) struct FabroRunInteractParams { pub(crate) message: Option, pub(crate) interrupt: Option, pub(crate) question_id: Option, - pub(crate) answer: Option, + pub(crate) answer: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(transparent)] +pub(crate) struct AnswerValue(Value); + +impl From for AnswerValue { + fn from(value: Value) -> Self { + Self(value) + } +} + +impl AnswerValue { + fn into_inner(self) -> Value { + self.0 + } +} + +impl JsonSchema for AnswerValue { + fn inline_schema() -> bool { + true + } + + fn schema_name() -> Cow<'static, str> { + "AnswerValue".into() + } + + fn json_schema(_: &mut SchemaGenerator) -> Schema { + json_schema!({ + "description": "Answer payload for a pending Fabro question. Use a boolean for yes/no, a string or {\"text\": \"...\"} for freeform text, {\"option\": \"key\"} for a single choice, or {\"options\": [\"key\"]} for multi-select.", + "anyOf": [ + { "type": "boolean" }, + { "type": "string" }, + { + "type": "object", + "properties": { + "option": { "type": "string" } + }, + "required": ["option"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "options": { + "type": "array", + "items": { "type": "string" } + } + }, + "required": ["options"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "text": { "type": "string" } + }, + "required": ["text"], + "additionalProperties": false + } + ] + }) + } } #[derive(Debug)] @@ -116,7 +180,7 @@ impl TryFrom for ValidatedInteractRun { }; ValidatedInteractAction::Answer { question_id: question_id.to_string(), - body: answer_to_submit_request(answer)?, + body: answer_to_submit_request(answer.into_inner())?, } } }; @@ -326,7 +390,7 @@ mod tests { message: None, interrupt: None, question_id: Some("question-1".to_string()), - answer: Some(json!({ "value": "yes" })), + answer: Some(json!({ "value": "yes" }).into()), }) .unwrap_err();