fix(mcp): emit explicit answer input schema

Avoid advertising fabro_run_interact.answer as the boolean JSON Schema true, which some MCP clients reject during tool discovery.
This commit is contained in:
Bryan Helmkamp 2026-05-11 14:51:32 -04:00
parent 503601f8be
commit 6d016d69a1
No known key found for this signature in database
2 changed files with 90 additions and 5 deletions

View file

@ -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

View file

@ -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<String>,
pub(crate) interrupt: Option<bool>,
pub(crate) question_id: Option<String>,
pub(crate) answer: Option<Value>,
pub(crate) answer: Option<AnswerValue>,
}
#[derive(Debug, Deserialize)]
#[serde(transparent)]
pub(crate) struct AnswerValue(Value);
impl From<Value> 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<FabroRunInteractParams> 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();