Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_proposal_confirm.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"crates/devup-mcp/Cargo.toml": "Patch"
},
"note": "Accept tool arguments a client sent as strings where the schema wants a number, boolean, array or object. devup_screen_plan draft now returns `proposal` and `proposalMarkdown`, a single table of every open decision with the recommended answer, so the user confirms the whole plan in one question asked in their own language instead of answering decision by decision. confirm takes `acceptRecommended: true`: any applicable question left out of `answers` gets its recommended option. When confirm would put two screens on one URL it writes nothing and returns `details.conflict` and `details.retryAnswers`, which suggests a different URL to retry with.",
"date": "2026-10-19T00:00:00+09:00"
}
153 changes: 153 additions & 0 deletions crates/devup-mcp/src/server/coerce_args.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
use serde_json::Value;

#[cfg(test)]
use serde_json::json;

/// Declared JSON types of a property schema, following `$ref`, `anyOf`,
/// `oneOf` and `allOf` into the root `$defs`. Depth-limited so a cyclic
/// schema cannot loop.
fn declared_types(
root: &serde_json::Map<String, Value>,
schema: &Value,
depth: u8,
out: &mut Vec<String>,
) {
if depth > 6 {
return;
}
let Some(object) = schema.as_object() else {
return;
};
match object.get("type") {
Some(Value::String(kind)) => out.push(kind.clone()),
Some(Value::Array(kinds)) => {
out.extend(kinds.iter().filter_map(|k| k.as_str().map(str::to_owned)))
}
_ => {}
}
if !out.iter().any(|t| t == "string")
&& object
.get("enum")
.and_then(Value::as_array)
.is_some_and(|values| values.iter().all(Value::is_string))
{
out.push("string".to_owned());
}
if let Some(reference) = object.get("$ref").and_then(Value::as_str)
&& let Some(name) = reference.rsplit('/').next()
&& let Some(target) = ["$defs", "definitions"]
.iter()
.find_map(|key| root.get(*key).and_then(|defs| defs.get(name)))
{
declared_types(root, target, depth + 1, out);
}
for key in ["anyOf", "oneOf", "allOf"] {
if let Some(Value::Array(branches)) = object.get(key) {
for branch in branches {
declared_types(root, branch, depth + 1, out);
}
}
}
}

/// Some MCP clients send a structured argument as its JSON text (`"[\"tsx\"]"`
/// for an array, `"true"` for a boolean, `"60000"` for a number). The schema
/// says what the field is, so a string that parses as exactly that is
/// accepted instead of failing the first call. A field that is declared to
/// accept a string is never touched.
pub(super) fn coerce_stringified_arguments(
schema: &serde_json::Map<String, Value>,
arguments: &mut serde_json::Map<String, Value>,
) {
let Some(Value::Object(properties)) = schema.get("properties") else {
return;
};
for (key, value) in arguments.iter_mut() {
let Value::String(text) = value else {
continue;
};
let Some(property) = properties.get(key) else {
continue;
};
let mut types = Vec::new();
declared_types(schema, property, 0, &mut types);
if types.is_empty() || types.iter().any(|t| t == "string") {
continue;
}
let Ok(parsed) = serde_json::from_str::<Value>(text.trim()) else {
continue;
};
let accepted = types.iter().any(|kind| match kind.as_str() {
"array" => parsed.is_array(),
"object" => parsed.is_object(),
"boolean" => parsed.is_boolean(),
"integer" => parsed.is_i64() || parsed.is_u64(),
"number" => parsed.is_number(),
"null" => parsed.is_null(),
_ => false,
});
if accepted {
*value = parsed;
}
}
}

#[cfg(test)]
mod coerce_tests {
use super::*;

fn schema() -> serde_json::Map<String, Value> {
json!({
"type": "object",
"properties": {
"outputs": {"type": "array", "items": {"type": "string"}},
"refresh": {"type": "boolean"},
"limit": {"type": ["integer", "null"]},
"url": {"type": ["string", "null"]},
"mode": {"anyOf": [{"$ref": "#/$defs/Mode"}, {"type": "null"}]},
"plan": {"anyOf": [{"$ref": "#/$defs/Plan"}, {"type": "null"}]}
},
"$defs": {
"Mode": {"type": "string", "enum": ["a", "b"]},
"Plan": {"type": "object"}
}
})
.as_object()
.unwrap()
.clone()
}

fn run(args: Value) -> Value {
let mut args = args.as_object().unwrap().clone();
coerce_stringified_arguments(&schema(), &mut args);
Value::Object(args)
}

#[test]
fn stringified_structured_values_are_decoded() {
let out = run(json!({
"outputs": "[\"tsx\"]",
"refresh": "true",
"limit": "20",
"plan": "{\"a\":1}"
}));
assert_eq!(out["outputs"], json!(["tsx"]));
assert_eq!(out["refresh"], json!(true));
assert_eq!(out["limit"], json!(20));
assert_eq!(out["plan"], json!({"a": 1}));
}

#[test]
fn string_fields_and_mismatches_are_left_alone() {
let out = run(json!({
"url": "[\"x\"]",
"mode": "[1]",
"outputs": "tsx",
"limit": "abc"
}));
assert_eq!(out["url"], json!("[\"x\"]"));
assert_eq!(out["mode"], json!("[1]"));
assert_eq!(out["outputs"], json!("tsx"));
assert_eq!(out["limit"], json!("abc"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ fn apply_explore_limit(mut response: Value, limit: usize) -> Value {
response
}

#[path = "../coerce_args.rs"]
mod coerce_args;
#[path = "../handlers/devup_figma_measure.rs"]
mod devup_figma_measure_handler;
#[path = "../handlers/devup_skills.rs"]
Expand Down Expand Up @@ -735,6 +737,12 @@ impl ServerHandler for DevupServer {
{
let _ = self.client_name.set(peer.client_info.name.clone());
}
let mut request = request;
if let (Some(tool), Some(arguments)) =
(self.tool_router.get(request.name.as_ref()), request.arguments.as_mut())
{
coerce_args::coerce_stringified_arguments(&tool.input_schema, arguments);
}
let call = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
let mut response = match self.tool_router.call(call).await {
Ok(response) => response,
Expand Down Expand Up @@ -958,4 +966,4 @@ mod r8_recovery_tests {
}
}
}
}
}
13 changes: 13 additions & 0 deletions crates/devup-mcp/src/server/project_plan/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ pub struct ProjectPlanInput {
#[serde(default)]
#[schemars(extend("additionalProperties" = json!({})))]
pub answers: BTreeMap<String, Value>,
/// draft/confirm: the user accepted the proposal table; every question
/// missing from `answers` takes its recommended option.
#[serde(default)]
pub accept_recommended: bool,
#[serde(default)]
pub features: Vec<Feature>,
/// Explicit ERD models in vespertide wire format; confirm may refine the draft.
Expand Down Expand Up @@ -174,7 +178,10 @@ fn response(plan: Value) -> Value {
.filter(|q| plan["answers"][text(&q["id"])].is_null())
.cloned()
.collect::<Vec<_>>();
let proposal = screen_plan::proposal(&questions);
json!({"planId":plan["planId"], "state":plan["state"],
"proposalMarkdown":screen_plan::proposal_markdown(&proposal),
"proposal":proposal,
"questions":screen_plan::ask_shape(&questions),
"questionRounds":screen_plan::question_rounds(&questions, 4),
"instruction": if plan["state"] == "confirmed" { include_str!("../text/mod-response-22d8f704.md") } else { screen_plan::ASK_INSTRUCTION },
Expand Down Expand Up @@ -221,6 +228,9 @@ pub(super) async fn run(
return Err(invalid("Confirmed plans cannot be redrafted"));
}
rounds::answers(&mut plan, &input.answers)?;
if input.accept_recommended {
rounds::accept_recommended(&mut plan)?;
}
rounds::require_answers(&plan)?;
for ev in &input.evidence {
if ev.source.trim().is_empty() {
Expand Down Expand Up @@ -259,6 +269,9 @@ pub(super) async fn run(
return Err(invalid("Draft the complete plan before confirm"));
}
rounds::answers(&mut plan, &input.answers)?;
if input.accept_recommended {
rounds::accept_recommended(&mut plan)?;
}
rounds::require_answers(&plan)?;
if plan["answers"]
.as_object()
Expand Down
12 changes: 12 additions & 0 deletions crates/devup-mcp/src/server/project_plan/rounds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,18 @@ pub(super) fn answers(
}
Ok(())
}
pub(super) fn accept_recommended(plan: &mut Value) -> Result<(), DevupError> {
let fill = array(&plan["questions"])
.iter()
.filter(|q| plan["answers"][text(&q["id"])].is_null())
.filter_map(|q| {
array(&q["resolve"])
.first()
.map(|r| (text(&q["id"]).to_owned(), r["label"].clone()))
})
.collect::<BTreeMap<_, _>>();
answers(plan, &fill)
}
pub(super) fn require_answers(plan: &Value) -> Result<(), DevupError> {
let missing = array(&plan["questions"])
.iter()
Expand Down
13 changes: 13 additions & 0 deletions crates/devup-mcp/src/server/project_plan/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,19 @@ async fn figma_erd_confirm_writes_schema_valid_models() {

}
#[tokio::test]
async fn accepting_the_proposal_confirms_without_answers() {
let dir = TestDir::new();
let (mut i, policy, draft) = drafted(dir.path(), true).await;
assert_eq!(
array(&draft["proposal"]).len(),
array(&draft["plan"]["questions"]).len()
);
i.answers.clear();
i.accept_recommended = true;
let r = run(&i, &policy).await.unwrap();
assert_eq!(r["state"], "confirmed");
}
#[tokio::test]
async fn many_to_many_join_table_and_gapped_roles_validate() {
let dir = TestDir::new();
let (mut i, policy, r) = drafted(dir.path(), true).await;
Expand Down
81 changes: 76 additions & 5 deletions crates/devup-mcp/src/server/screen_plan/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,13 @@ pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result<
continue;
}
let Some(answer) = input.answers.get(&id) else {
missing.push(id);
if input.accept_recommended
&& let Some(recommended) = array(&question["resolve"]).first()
{
actions.insert(id, recommended["action"].clone());
} else {
missing.push(id);
}
continue;
};
let (action, note) = resolve_answer(question, answer)?;
Expand Down Expand Up @@ -265,10 +271,7 @@ pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result<
)
&& other != text(&row["screen"])
{
return Err(invalid(format!(
"Two screens resolve to {url} ({other} and {}); answer their route questions with different URLs.",
text(&row["screen"])
)));
return Err(duplicate_route(&plan, url, &other, &row["screen"], &row["name"]));
}
}
for row in routes
Expand Down Expand Up @@ -552,6 +555,74 @@ pub(super) async fn audit_plan(

pub(super) const DONE_RULE: &str = include_str!("../text/screen_plan-DONE_RULE-95793a55.md");

/// The question ids that place `screen` (a screen id, or a frame node id for
/// a frame split into its own page).
fn placing_questions(plan: &Value, screen: &str) -> Vec<String> {
array(&plan["questions"])
.iter()
.map(|question| text(&question["id"]))
.filter(|id| {
["route:", "review:", "modal:", "group:", "split-route:", "collision:"]
.iter()
.any(|prefix| id.strip_prefix(prefix) == Some(screen))
})
.map(str::to_owned)
.collect()
}

fn suggested_route(name: &str, screen: &str, taken: &str) -> String {
let words = name
.to_lowercase()
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|word| !word.is_empty())
.map(str::to_owned)
.collect::<Vec<_>>();
let base = if words.is_empty() {
format!("/page-{}", screen.replace([':', ';'], "-"))
} else {
format!("/{}", words.join("-"))
};
if base == taken {
format!("{base}-{}", screen.replace([':', ';'], "-"))
} else {
base
}
}

/// Two screens on one URL: the error names both screens, the questions that
/// place each, and a distinct URL for the second, so the caller can retry
/// confirm with one changed answer instead of guessing.
fn duplicate_route(plan: &Value, url: &str, first: &str, second: &Value, name: &Value) -> DevupError {
let second = text(second);
let first_name = array(&plan["screens"])
.iter()
.find(|screen| screen["id"] == first)
.map(|screen| screen["name"].clone())
.unwrap_or(Value::Null);
let suggestion = suggested_route(text(name), second, url);
let questions = placing_questions(plan, second);
let retry = questions
.iter()
.find(|id| !id.starts_with("collision:"))
.map(|id| json!({id.as_str(): suggestion}))
.unwrap_or(json!({}));
DevupError::with_details(
ErrorCode::DevupInvalidInput,
format!(
"Two screens resolve to {url} ({first} and {second}). Nothing was written. Retry confirm with the same answers plus {retry}, or show the user both screens and ask which one owns {url}."
),
false,
json!({
"url": url,
"conflict": [
{"screen": first, "name": first_name, "questions": placing_questions(plan, first)},
{"screen": second, "name": name, "questions": questions, "suggestedUrl": suggestion},
],
"retryAnswers": retry,
}),
)
}

fn screen_table(plan: &Value) -> Vec<Value> {
array(&plan["screens"])
.iter()
Expand Down
Loading
Loading