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
131 changes: 131 additions & 0 deletions src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,26 @@ fn add_stateless_workflow_recorder_metadata(payload: &mut Value, model_surface:
"description": "MCP wrapper metadata only. ACK means the current model context still remembers the referenced open Session message. Repeat ACK ids on subsequent calls while remembered. If omitted later, unresolved ACK-required guidance may be returned again. ACK does not resolve the message."
}),
);
properties.insert(
crate::tool_runtime::sessions::TOOL_CALL_SESSION_MESSAGE_RESOLUTION_FIELD.to_string(),
json!({
"type": "object",
"description": "MCP wrapper metadata only. After one non-todo message in recording_session_id is already handled, resolve it and attach bounded resolution text on this same WebCodex call instead of making a separate resolve call. For requires_ack guidance, include the same message_id in ack_session_message_ids on this request. The target is always the exact recording Session and this object is removed before concrete tool parsing. Do not use it to predict whether the current tool call will succeed; todo completion still uses complete_session_message.",
"properties": {
"message_id": {
"type": "string",
"pattern": "^wc_msg_[A-Za-z0-9_]+$"
},
"resolution": {
"type": "string",
"minLength": 1,
"maxLength": crate::tool_runtime::sessions::MAX_MESSAGE_RESOLUTION_CHARS
}
},
"required": ["message_id", "resolution"],
"additionalProperties": false
}),
);
if matches!(model_surface, ModelSurface::FullOperatorRuntime) {
properties.insert(
crate::tool_runtime::sessions::TOOL_CALL_ACK_SESSION_CONTEXT_REVISION_FIELD
Expand Down Expand Up @@ -3122,6 +3142,56 @@ async fn handle_mcp_request_with_lifecycle(
} else {
Vec::new()
};
let session_message_resolution = if stateless_2026 {
if let Some(arguments) = params.arguments.as_object_mut() {
arguments.remove(
crate::tool_runtime::sessions::TOOL_CALL_SESSION_MESSAGE_RESOLUTION_INTERNAL_FIELD,
);
}
match strip_stateless_session_message_resolution(&mut params.arguments) {
Ok(value) => value,
Err(message) => {
if let Some(lc) = lifecycle.as_deref() {
lc.dispatch_failed("invalid_arguments");
lc.dispatch_finished(false, Some(false), "invalid_arguments");
}
if let (Some(slot), Some(timer)) = (
model_ergonomics_out.as_deref_mut(),
pre_kernel_model_ergonomics.take(),
) {
*slot = Some(
timer
.finish()
.record_for_pre_result_failure("invalid_arguments"),
);
}
return McpOutcome::BadRequest(rpc_error(id, -32602, message));
}
}
} else {
None
};
if session_message_resolution.is_some() && session_id.is_none() {
let message = format!(
"field '{}' requires '{}' for the exact target Workflow Session",
crate::tool_runtime::sessions::TOOL_CALL_SESSION_MESSAGE_RESOLUTION_FIELD,
crate::tool_runtime::sessions::TOOL_CALL_RECORDING_SESSION_ID_FIELD,
);
if let Some(lc) = lifecycle.as_deref() {
lc.dispatch_failed("invalid_arguments");
lc.dispatch_finished(false, Some(false), "invalid_arguments");
}
return McpOutcome::BadRequest(rpc_error(id, -32602, message));
}
if let (Some(arguments), Some(resolution)) =
(params.arguments.as_object_mut(), session_message_resolution)
{
arguments.insert(
crate::tool_runtime::sessions::TOOL_CALL_SESSION_MESSAGE_RESOLUTION_INTERNAL_FIELD
.to_string(),
json!(resolution),
);
}
if !ack_session_message_ids.is_empty() {
if let Some(arguments) = params.arguments.as_object_mut() {
arguments.insert(
Expand Down Expand Up @@ -3618,6 +3688,67 @@ fn strip_stateless_ack_session_message_ids(arguments: &mut Value) -> Result<Vec<
Ok(normalized)
}

fn strip_stateless_session_message_resolution(
arguments: &mut Value,
) -> Result<Option<crate::tool_runtime::sessions::ToolCallSessionMessageResolution>, String> {
let Some(object) = arguments.as_object_mut() else {
return Ok(None);
};
let Some(value) =
object.remove(crate::tool_runtime::sessions::TOOL_CALL_SESSION_MESSAGE_RESOLUTION_FIELD)
else {
return Ok(None);
};
let Value::Object(mut fields) = value else {
return Err(format!(
"field '{}' must be an object with message_id and resolution",
crate::tool_runtime::sessions::TOOL_CALL_SESSION_MESSAGE_RESOLUTION_FIELD
));
};
if fields.len() != 2 || !fields.contains_key("message_id") || !fields.contains_key("resolution")
{
return Err(format!(
"field '{}' accepts exactly message_id and resolution",
crate::tool_runtime::sessions::TOOL_CALL_SESSION_MESSAGE_RESOLUTION_FIELD
));
}
let Some(Value::String(message_id)) = fields.remove("message_id") else {
return Err("session_message_resolution.message_id must be a wc_msg_* string".to_string());
};
let message_id = message_id.trim().to_string();
let valid_message_id = message_id.strip_prefix("wc_msg_").is_some_and(|suffix| {
!suffix.is_empty()
&& suffix
.as_bytes()
.iter()
.all(|byte| byte.is_ascii_alphanumeric() || *byte == b'_')
});
if !valid_message_id {
return Err(
"session_message_resolution.message_id must be a valid wc_msg_* id".to_string(),
);
}
let Some(Value::String(resolution)) = fields.remove("resolution") else {
return Err("session_message_resolution.resolution must be a string".to_string());
};
let resolution = resolution.trim().to_string();
if resolution.is_empty() {
return Err("session_message_resolution.resolution must not be empty".to_string());
}
if resolution.chars().count() > crate::tool_runtime::sessions::MAX_MESSAGE_RESOLUTION_CHARS {
return Err(format!(
"session_message_resolution.resolution exceeds {} chars",
crate::tool_runtime::sessions::MAX_MESSAGE_RESOLUTION_CHARS
));
}
Ok(Some(
crate::tool_runtime::sessions::ToolCallSessionMessageResolution {
message_id,
resolution,
},
))
}

fn strip_stateless_ack_session_context_revision(arguments: &mut Value) -> Option<Value> {
arguments
.as_object_mut()?
Expand Down
119 changes: 119 additions & 0 deletions src/mcp_tests/http_transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -897,6 +897,123 @@ async fn http_mcp_2026_request_scoped_ack_redelivers_until_durable_resolution()
);
assert!(second_stored[0].first_ack_observed_at.is_some());

let (status, third_post_body) = stateless_2026_tool_call(
&service,
"secret",
229,
"post_session_message",
json!({
"session_id": session_id,
"kind": "guidance",
"priority": "high",
"requires_ack": true,
"message": "Resolve this without a dedicated resolve tool call."
}),
None,
)
.await;
assert_eq!(status, StatusCode::OK, "{third_post_body}");
let third_message_id = stateless_tool_output(&third_post_body)["message_id"]
.as_str()
.unwrap()
.to_string();
let resolution_text = "handled through ordinary list_tools wrapper metadata";

let missing_ack_args = with_mcp_recording_session(
json!({
crate::tool_runtime::sessions::TOOL_CALL_SESSION_MESSAGE_RESOLUTION_FIELD: {
"message_id": third_message_id,
"resolution": resolution_text
}
}),
&session_id,
);
let (status, missing_ack_body) = stateless_2026_tool_call(
&service,
"secret",
230,
"list_tools",
missing_ack_args,
None,
)
.await;
assert_eq!(status, StatusCode::OK, "{missing_ack_body}");
assert_eq!(
stateless_tool_output(&missing_ack_body)["error_kind"],
"invalid_session_message"
);
let still_open = runtime
.sessions
.list_messages(
&session_id,
crate::tool_runtime::sessions::ListSessionMessagesFilter {
message_id: Some(third_message_id.clone()),
..Default::default()
},
)
.unwrap();
assert_eq!(
still_open[0].status,
crate::tool_runtime::sessions::SessionMessageStatus::Open
);

let mut piggyback_args = with_mcp_recording_session(
json!({
crate::tool_runtime::sessions::TOOL_CALL_SESSION_MESSAGE_RESOLUTION_FIELD: {
"message_id": third_message_id,
"resolution": resolution_text
}
}),
&session_id,
);
piggyback_args.as_object_mut().unwrap().insert(
crate::tool_runtime::sessions::TOOL_CALL_ACK_SESSION_MESSAGE_IDS_FIELD.to_string(),
json!([third_message_id]),
);
let (status, piggyback_body) = stateless_2026_tool_call(
&service,
"secret",
231,
"list_tools",
piggyback_args.clone(),
None,
)
.await;
assert_eq!(status, StatusCode::OK, "{piggyback_body}");
assert_eq!(piggyback_body["result"]["isError"], false);
let piggyback_output = stateless_tool_output(&piggyback_body);
assert_eq!(
piggyback_output["session_attention"]["ack"]["accepted_count"],
1
);
assert!(piggyback_output["session_attention"]["messages"]
.as_array()
.unwrap()
.is_empty());
let piggyback_stored = runtime
.sessions
.list_messages(
&session_id,
crate::tool_runtime::sessions::ListSessionMessagesFilter {
message_id: Some(third_message_id.clone()),
..Default::default()
},
)
.unwrap();
assert_eq!(
piggyback_stored[0].status,
crate::tool_runtime::sessions::SessionMessageStatus::Resolved
);
assert_eq!(
piggyback_stored[0].resolution.as_deref(),
Some(resolution_text)
);

let (status, replay_body) =
stateless_2026_tool_call(&service, "secret", 232, "list_tools", piggyback_args, None).await;
assert_eq!(status, StatusCode::OK, "{replay_body}");
assert_eq!(replay_body["result"]["isError"], false);

let audit = serde_json::to_string(
&runtime
.sessions
Expand All @@ -907,6 +1024,8 @@ async fn http_mcp_2026_request_scoped_ack_redelivers_until_durable_resolution()
.unwrap();
assert!(!audit.contains("ack_session_message_ids"));
assert!(!audit.contains("__webcodex_stateless_ack_session_message_ids"));
assert!(!audit.contains("__webcodex_stateless_session_message_resolution"));
assert!(!audit.contains("handled through ordinary list_tools wrapper metadata"));
}

#[tokio::test]
Expand Down
22 changes: 22 additions & 0 deletions src/mcp_tests/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,21 @@ async fn mcp_stateless_tools_list_uses_2026_result_shape() {
let description = ack["description"].as_str().unwrap();
assert!(description.contains("current model context still remembers"));
assert!(description.contains("ACK does not resolve"));
let resolution = &read_files["inputSchema"]["properties"]["session_message_resolution"];
assert_eq!(resolution["type"], "object");
assert_eq!(
resolution["properties"]["message_id"]["pattern"],
"^wc_msg_[A-Za-z0-9_]+$"
);
assert_eq!(resolution["properties"]["resolution"]["minLength"], 1);
assert_eq!(
resolution["properties"]["resolution"]["maxLength"],
crate::tool_runtime::sessions::MAX_MESSAGE_RESOLUTION_CHARS
);
let resolution_description = resolution["description"].as_str().unwrap();
assert!(resolution_description.contains("same WebCodex call"));
assert!(resolution_description.contains("recording_session_id"));
assert!(resolution_description.contains("complete_session_message"));
let context_ack =
&read_files["inputSchema"]["properties"]["ack_session_context_revision"];
assert_eq!(context_ack["type"], "integer");
Expand Down Expand Up @@ -242,6 +257,13 @@ async fn mcp_legacy_tools_list_omits_2026_only_result_fields() {
.all(|tool| tool["inputSchema"]["properties"]
.get("ack_session_message_ids")
.is_none()));
assert!(value["result"]["tools"]
.as_array()
.unwrap()
.iter()
.all(|tool| tool["inputSchema"]["properties"]
.get("session_message_resolution")
.is_none()));
assert!(value["result"]["tools"]
.as_array()
.unwrap()
Expand Down
Loading
Loading