use crate::error::McpClientResult;
use serde_json::Value;
use turul_mcp_protocol_2026_07_28 as p;
pub(crate) fn request_meta(
client_name: &str,
client_version: &str,
declared: &crate::config::DeclaredCapabilities,
) -> p::meta::RequestMetaObject {
#[allow(deprecated)]
let capabilities = p::initialize::ClientCapabilities {
elicitation: (declared.elicitation || declared.elicitation_url).then(|| {
p::initialize::ElicitationCapabilities {
form: Some(Default::default()),
url: declared.elicitation_url.then(Default::default),
..Default::default()
}
}),
sampling: (declared.sampling || declared.sampling_tools || declared.sampling_context).then(
|| p::initialize::SamplingCapabilities {
tools: declared.sampling_tools.then(Default::default),
context: declared.sampling_context.then(Default::default),
..Default::default()
},
),
roots: declared.roots.then(Default::default),
extensions: declared.ext_tasks.then(|| {
std::collections::HashMap::from([(
"io.modelcontextprotocol/tasks".to_string(),
serde_json::json!({}),
)])
}),
..Default::default()
};
p::meta::RequestMetaObject::new(
p::MCP_VERSION,
p::initialize::Implementation::new(client_name.to_string(), client_version.to_string()),
capabilities,
)
}
pub(crate) fn check_result_type(result: &Value) -> Result<(), crate::error::McpClientError> {
match result.get("resultType").and_then(|v| v.as_str()) {
None | Some("complete") => Ok(()),
Some("input_required") => Err(crate::error::McpClientError::InputRequired {
input_requests: result.get("inputRequests").cloned(),
request_state: result
.get("requestState")
.and_then(|v| v.as_str())
.map(String::from),
}),
Some(other) => Err(crate::error::ProtocolError::InvalidResponse(format!(
"unrecognized resultType {other:?}"
))
.into()),
}
}
pub(crate) fn params_with_meta(meta: &p::meta::RequestMetaObject, extra: Value) -> Value {
let mut map = match extra {
Value::Object(m) => m,
_ => serde_json::Map::new(),
};
map.insert(
"_meta".to_string(),
serde_json::to_value(meta).unwrap_or(Value::Null),
);
Value::Object(map)
}
fn remap<A, B>(v: &A) -> McpClientResult<B>
where
A: serde::Serialize,
B: serde::de::DeserializeOwned,
{
Ok(serde_json::from_value(serde_json::to_value(v)?)?)
}
fn remap_tool(tool: &p::tools::Tool) -> turul_mcp_protocol_2025_11_25::Tool {
let value = serde_json::to_value(tool).unwrap_or_default();
if let Ok(remapped) =
serde_json::from_value::<turul_mcp_protocol_2025_11_25::Tool>(value.clone())
{
return remapped;
}
let mut dropped: Vec<String> = Vec::new();
let input_schema = downgrade_schema(value.get("inputSchema"), "inputSchema", &mut dropped)
.unwrap_or_else(turul_mcp_protocol_2025_11_25::tools::ToolSchema::object);
let mut remapped = turul_mcp_protocol_2025_11_25::Tool::new(tool.name.clone(), input_schema);
remapped.title = tool.title.clone();
remapped.description = tool.description.clone();
remapped.output_schema =
downgrade_schema(value.get("outputSchema"), "outputSchema", &mut dropped);
remapped.annotations = downgrade_field(&value, "annotations", &mut dropped);
remapped.icons = downgrade_field(&value, "icons", &mut dropped);
remapped.meta = downgrade_field(&value, "_meta", &mut dropped);
tracing::warn!(
tool = %tool.name,
dropped = %dropped.join(", "),
"tools/list: schema detail dropped — this tool's 2026 definition uses \
constructs the client's public Tool vocabulary cannot hold; the tool is \
still listed and still callable, and the full schema is available from \
McpClient::tool_input_schema"
);
remapped
}
fn downgrade_schema(
schema: Option<&Value>,
path: &str,
dropped: &mut Vec<String>,
) -> Option<turul_mcp_protocol_2025_11_25::tools::ToolSchema> {
let mut schema = schema?.clone();
if let Some(properties) = schema.get_mut("properties").and_then(|p| p.as_object_mut()) {
let unrepresentable: Vec<String> = properties
.iter()
.filter(|(_, v)| {
serde_json::from_value::<turul_mcp_protocol_2025_11_25::JsonSchema>((*v).clone())
.is_err()
})
.map(|(k, _)| k.clone())
.collect();
for name in unrepresentable {
properties.remove(&name);
dropped.push(format!("{path}.properties.{name}"));
}
}
match serde_json::from_value(schema) {
Ok(schema) => Some(schema),
Err(_) => {
dropped.push(path.to_string());
None
}
}
}
fn downgrade_field<T: serde::de::DeserializeOwned>(
tool: &Value,
key: &str,
dropped: &mut Vec<String>,
) -> Option<T> {
match serde_json::from_value(tool.get(key)?.clone()) {
Ok(value) => Some(value),
Err(_) => {
dropped.push(key.to_string());
None
}
}
}
fn tool_is_admissible(tool: &p::tools::Tool) -> bool {
let schema = serde_json::to_value(&tool.input_schema).unwrap_or_default();
if let Err(reason) = p::headers::scan_x_mcp_headers(&schema) {
tracing::warn!(
tool = %tool.name,
%reason,
"excluding tool from tools/list: invalid x-mcp-header annotation"
);
return false;
}
if let Some(pointer) = p::headers::find_misplaced_x_mcp_header(&schema) {
tracing::warn!(
tool = %tool.name,
%pointer,
"excluding tool from tools/list: x-mcp-header annotation not reachable via a properties chain"
);
return false;
}
if let Err(reason) = schema_is_valid(&schema) {
tracing::warn!(
tool = %tool.name,
%reason,
"excluding tool from tools/list: invalid inputSchema"
);
return false;
}
true
}
fn remap_tools(tools: &[p::tools::Tool]) -> Vec<turul_mcp_protocol_2025_11_25::Tool> {
tools
.iter()
.filter(|tool| tool_is_admissible(tool))
.map(remap_tool)
.collect()
}
fn schema_is_valid(schema: &Value) -> Result<(), String> {
turul_mcp_schema_validation::validate_tool_input_schema(schema).map_err(|e| e.to_string())
}
pub(crate) fn parse_list_tools(
result: &Value,
) -> McpClientResult<Vec<turul_mcp_protocol_2025_11_25::Tool>> {
check_result_type(result)?;
let r: p::tools::ListToolsResult = serde_json::from_value(result.clone())?;
Ok(remap_tools(&r.tools))
}
pub(crate) fn collect_input_schemas(result: &Value) -> std::collections::HashMap<String, Value> {
let mut map = std::collections::HashMap::new();
let Some(tools) = result.get("tools").and_then(|t| t.as_array()) else {
return map;
};
for tool in tools {
if let (Some(name), Some(schema)) = (
tool.get("name").and_then(|n| n.as_str()),
tool.get("inputSchema"),
) {
map.insert(name.to_string(), schema.clone());
}
}
map
}
pub(crate) fn collect_param_bindings(
result: &Value,
) -> std::collections::HashMap<String, Vec<(String, String, String)>> {
let mut map = std::collections::HashMap::new();
let Some(tools) = result.get("tools").and_then(|t| t.as_array()) else {
return map;
};
for tool in tools {
let (Some(name), Some(schema)) = (
tool.get("name").and_then(|n| n.as_str()),
tool.get("inputSchema"),
) else {
continue;
};
if let Ok(bindings) = p::headers::scan_x_mcp_headers(schema)
&& !bindings.is_empty()
{
map.insert(
name.to_string(),
bindings
.into_iter()
.map(|b| (b.header_name, b.argument_pointer, b.schema_type))
.collect(),
);
}
}
map
}
pub(crate) fn encode_param_headers(
bindings: &[(String, String, String)],
arguments: &Value,
) -> Vec<(String, String)> {
let mut headers = Vec::new();
for (header_name, pointer, _schema_type) in bindings {
let Some(value) = arguments.pointer(pointer).filter(|v| !v.is_null()) else {
continue;
};
if let Some(encoded) = p::headers::encode_param_value(value) {
headers.push((format!("Mcp-Param-{header_name}"), encoded));
}
}
headers
}
pub(crate) fn parse_call_tool(
result: &Value,
) -> McpClientResult<turul_mcp_protocol_2025_11_25::CallToolResult> {
check_result_type(result)?;
let r: p::tools::CallToolResult = serde_json::from_value(result.clone())?;
remap(&r)
}
pub(crate) fn parse_list_resources(
result: &Value,
) -> McpClientResult<Vec<turul_mcp_protocol_2025_11_25::Resource>> {
check_result_type(result)?;
let r: p::resources::ListResourcesResult = serde_json::from_value(result.clone())?;
r.resources.iter().map(remap).collect()
}
pub(crate) fn parse_list_resource_templates(
result: &Value,
) -> McpClientResult<Vec<turul_mcp_protocol_2025_11_25::resources::ResourceTemplate>> {
check_result_type(result)?;
let r: p::resources::ListResourceTemplatesResult = serde_json::from_value(result.clone())?;
r.resource_templates.iter().map(remap).collect()
}
pub(crate) fn parse_read_resource(
result: &Value,
) -> McpClientResult<turul_mcp_protocol_2025_11_25::ReadResourceResult> {
check_result_type(result)?;
let r: p::resources::ReadResourceResult = serde_json::from_value(result.clone())?;
remap(&r)
}
pub(crate) fn parse_complete(
result: &Value,
) -> McpClientResult<turul_mcp_protocol_2025_11_25::completion::CompleteResult> {
check_result_type(result)?;
let r: p::completion::CompleteResult = serde_json::from_value(result.clone())?;
Ok(turul_mcp_protocol_2025_11_25::completion::CompleteResult {
completion: turul_mcp_protocol_2025_11_25::completion::CompletionResult {
values: r.completion.values,
total: r.completion.total.map(|total| total as u32),
has_more: r.completion.has_more,
},
meta: r.meta.as_ref().map(remap).transpose()?,
})
}
pub(crate) fn parse_list_prompts(
result: &Value,
) -> McpClientResult<Vec<turul_mcp_protocol_2025_11_25::Prompt>> {
check_result_type(result)?;
let r: p::prompts::ListPromptsResult = serde_json::from_value(result.clone())?;
r.prompts.iter().map(remap).collect()
}
pub(crate) fn parse_get_prompt(
result: &Value,
) -> McpClientResult<turul_mcp_protocol_2025_11_25::GetPromptResult> {
check_result_type(result)?;
let r: p::prompts::GetPromptResult = serde_json::from_value(result.clone())?;
remap(&r)
}
pub(crate) fn parse_list_tools_result(
result: &Value,
) -> McpClientResult<turul_mcp_protocol_2025_11_25::ListToolsResult> {
check_result_type(result)?;
let r: p::tools::ListToolsResult = serde_json::from_value(result.clone())?;
Ok(turul_mcp_protocol_2025_11_25::ListToolsResult {
tools: remap_tools(&r.tools),
next_cursor: r.next_cursor.as_ref().map(remap).transpose()?,
meta: r.meta.as_ref().map(remap).transpose()?,
})
}
pub(crate) fn parse_list_resources_result(
result: &Value,
) -> McpClientResult<turul_mcp_protocol_2025_11_25::ListResourcesResult> {
check_result_type(result)?;
let r: p::resources::ListResourcesResult = serde_json::from_value(result.clone())?;
remap(&r)
}
pub(crate) fn parse_list_resource_templates_result(
result: &Value,
) -> McpClientResult<turul_mcp_protocol_2025_11_25::resources::ListResourceTemplatesResult> {
check_result_type(result)?;
let r: p::resources::ListResourceTemplatesResult = serde_json::from_value(result.clone())?;
remap(&r)
}
pub(crate) fn parse_list_prompts_result(
result: &Value,
) -> McpClientResult<turul_mcp_protocol_2025_11_25::ListPromptsResult> {
check_result_type(result)?;
let r: p::prompts::ListPromptsResult = serde_json::from_value(result.clone())?;
remap(&r)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn unrecognized_result_type_is_invalid() {
assert!(check_result_type(&json!({"resultType": "complete"})).is_ok());
assert!(check_result_type(&json!({"content": []})).is_ok());
match check_result_type(&json!({"resultType": "partial"})) {
Err(crate::error::McpClientError::Protocol(_)) => {}
other => panic!("unrecognized resultType must be invalid, got: {other:?}"),
}
}
#[test]
fn ext_tasks_declaration_rides_request_meta_extensions() {
let declared = crate::config::DeclaredCapabilities {
ext_tasks: true,
..Default::default()
};
let meta = request_meta("t", "1", &declared);
let v = serde_json::to_value(&meta).unwrap();
assert_eq!(
v["io.modelcontextprotocol/clientCapabilities"]["extensions"]["io.modelcontextprotocol/tasks"],
serde_json::json!({})
);
let none = request_meta("t", "1", &Default::default());
let v = serde_json::to_value(&none).unwrap();
assert!(
v["io.modelcontextprotocol/clientCapabilities"]
.get("extensions")
.is_none(),
"no declaration without the opt-in: {v}"
);
}
#[test]
fn sub_capabilities_map_into_request_meta() {
let declared = crate::config::DeclaredCapabilities {
elicitation: true,
elicitation_url: true,
sampling: true,
sampling_tools: true,
sampling_context: false,
roots: false,
ext_tasks: false,
};
let meta = request_meta("t", "1", &declared);
let v = serde_json::to_value(&meta).unwrap();
let caps = &v["io.modelcontextprotocol/clientCapabilities"];
assert!(caps["elicitation"]["form"].is_object(), "{caps}");
assert!(caps["elicitation"]["url"].is_object(), "{caps}");
assert!(caps["sampling"]["tools"].is_object(), "{caps}");
assert!(caps["sampling"].get("context").is_none(), "{caps}");
let url_only = crate::config::DeclaredCapabilities {
elicitation_url: true,
..Default::default()
};
let v = serde_json::to_value(&request_meta("t", "1", &url_only)).unwrap();
assert!(v["io.modelcontextprotocol/clientCapabilities"]["elicitation"]["url"].is_object());
}
#[test]
fn bare_sampling_capability_alone_is_declared_in_request_meta() {
let declared = crate::config::DeclaredCapabilities {
sampling: true,
..Default::default()
};
let meta = request_meta("t", "1", &declared);
let v = serde_json::to_value(&meta).unwrap();
let caps = &v["io.modelcontextprotocol/clientCapabilities"];
assert!(
caps["sampling"].is_object(),
"declaring bare `sampling: true` must still emit a sampling capability object: {caps}"
);
assert!(caps["sampling"].get("tools").is_none(), "{caps}");
assert!(caps["sampling"].get("context").is_none(), "{caps}");
let none = request_meta("t", "1", &Default::default());
let v = serde_json::to_value(&none).unwrap();
assert!(
v["io.modelcontextprotocol/clientCapabilities"]
.get("sampling")
.is_none(),
"no declaration without the opt-in: {v}"
);
}
#[test]
fn a_typeless_output_schema_root_drops_the_field_not_the_tool() {
let result = json!({
"tools": [{
"name": "widen",
"inputSchema": {"type": "object", "properties": {"a": {"type": "string"}}},
"outputSchema": {"oneOf": [{"type": "string"}, {"type": "number"}]}
}],
"ttlMs": 0,
"cacheScope": "public"
});
let tools = parse_list_tools(&result).expect("listing must not fail");
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].name, "widen");
assert!(tools[0].output_schema.is_none());
assert!(
tools[0]
.input_schema
.properties
.as_ref()
.unwrap()
.contains_key("a")
);
}
#[test]
fn a_composition_property_is_dropped_but_the_tool_and_its_siblings_survive() {
let result = json!({
"tools": [
{
"name": "render",
"inputSchema": {
"type": "object",
"properties": {
"shape": {"oneOf": [{"type": "string"}, {"type": "number"}]},
"title": {"type": "string"}
},
"required": ["shape", "title"]
}
},
{"name": "echo", "inputSchema": {"type": "object"}}
],
"ttlMs": 0,
"cacheScope": "public"
});
let tools = parse_list_tools(&result).expect("listing must not fail");
assert_eq!(
tools.iter().map(|t| t.name.as_str()).collect::<Vec<_>>(),
vec!["render", "echo"]
);
let properties = tools[0].input_schema.properties.as_ref().unwrap();
assert!(properties.contains_key("title"), "sibling property kept");
assert!(!properties.contains_key("shape"), "composition dropped");
}
#[test]
fn input_required_surfaces_requests_and_state() {
match check_result_type(&json!({
"resultType": "input_required",
"inputRequests": {"q1": {"method": "elicitation/create"}},
"requestState": "st-1"
})) {
Err(crate::error::McpClientError::InputRequired {
input_requests,
request_state,
}) => {
assert!(input_requests.is_some());
assert_eq!(request_state.as_deref(), Some("st-1"));
}
other => panic!("expected InputRequired, got: {other:?}"),
}
}
}