Skip to main content

soma_mcp_server/
protocol.rs

1//! Reusable protocol conversion helpers between loose JSON/descriptor shapes
2//! and concrete `rmcp::model` types.
3//!
4//! Any inbound MCP server ends up doing this conversion at least twice: once
5//! for its own natively-defined tools (usually stored as plain JSON schema
6//! documents) and once for anything it re-projects from elsewhere (an
7//! upstream catalog, a provider registry, a gateway route). This module owns
8//! the generic half of both conversions; callers own the descriptor/schema
9//! source.
10
11use std::{borrow::Cow, sync::Arc};
12
13use rmcp::{
14    model::{Prompt, Resource, Tool, ToolAnnotations},
15    ErrorData,
16};
17use serde_json::{Map, Value};
18
19/// Convert an untyped MCP tool-definition JSON object
20/// (`{name, description, inputSchema, outputSchema}`) into an
21/// [`rmcp::model::Tool`].
22///
23/// This is the shape MCP clients expect from `tools/list`. It is the
24/// conversion a server needs when its tool catalog is stored as plain JSON
25/// (for example, provider-derived schemas) rather than built with the
26/// `rmcp-macros` tool-router machinery.
27pub fn tool_from_json_definition(value: Value) -> Result<Tool, ErrorData> {
28    let name = value
29        .get("name")
30        .and_then(Value::as_str)
31        .ok_or_else(|| ErrorData::internal_error("tool definition missing name", None))?;
32    let description = value
33        .get("description")
34        .and_then(Value::as_str)
35        .map(|d| Cow::Owned(d.to_string()));
36    let input_schema = value
37        .get("inputSchema")
38        .and_then(Value::as_object)
39        .cloned()
40        .ok_or_else(|| ErrorData::internal_error("tool definition missing inputSchema", None))?;
41    let mut tool = Tool::new_with_raw(
42        Cow::Owned(name.to_string()),
43        description,
44        Arc::new(input_schema),
45    );
46    if let Some(output_schema) = value.get("outputSchema") {
47        let output_schema = output_schema.as_object().cloned().ok_or_else(|| {
48            ErrorData::internal_error("tool outputSchema must be an object", None)
49        })?;
50        tool = tool.with_raw_output_schema(Arc::new(output_schema));
51    }
52    Ok(tool)
53}
54
55/// Build an [`rmcp::model::Tool`] from loose descriptor fields, defaulting a
56/// missing or non-object input schema to an empty JSON object schema and
57/// dropping a non-object output schema rather than failing.
58///
59/// Unlike [`tool_from_json_definition`], this never fails — it is meant for
60/// building tools from already-typed route/descriptor structs (upstream
61/// catalogs, gateway routes) where a malformed schema is a data-quality issue
62/// upstream, not a reason to drop the whole tool.
63pub fn tool_from_descriptor(
64    name: impl Into<Cow<'static, str>>,
65    description: Option<String>,
66    input_schema: Option<Value>,
67    output_schema: Option<Value>,
68    destructive: bool,
69) -> Tool {
70    let mut tool = Tool::new_with_raw(
71        name.into(),
72        description.map(Cow::Owned),
73        schema_object(input_schema),
74    );
75    if let Some(output_schema) = schema_object_opt(output_schema) {
76        tool = tool.with_raw_output_schema(output_schema);
77    }
78    tool.with_annotations(ToolAnnotations::new().destructive(destructive))
79}
80
81/// Build an [`rmcp::model::Resource`] from a resolved URI and display name.
82pub fn resource_from_descriptor(uri: impl Into<String>, name: impl Into<String>) -> Resource {
83    Resource::new(uri.into(), name.into())
84}
85
86/// Build an [`rmcp::model::Prompt`] from a name and optional description,
87/// with no declared arguments.
88pub fn prompt_from_descriptor(name: impl Into<String>, description: Option<&str>) -> Prompt {
89    Prompt::new(name.into(), description, None)
90}
91
92fn schema_object(value: Option<Value>) -> Arc<Map<String, Value>> {
93    schema_object_opt(value).unwrap_or_else(|| {
94        Arc::new(Map::from_iter([(
95            "type".to_owned(),
96            Value::String("object".to_owned()),
97        )]))
98    })
99}
100
101fn schema_object_opt(value: Option<Value>) -> Option<Arc<Map<String, Value>>> {
102    match value {
103        Some(Value::Object(map)) => Some(Arc::new(map)),
104        Some(other) => {
105            // A schema was supplied but is not a JSON object — a data-quality
106            // issue in the upstream/provider/route source, not a reason to
107            // fail the whole conversion (see `tool_from_descriptor`'s doc
108            // comment). Still worth a log: silently dropping it would leave
109            // no trace of a malformed upstream schema.
110            tracing::warn!(
111                schema.type = %schema_type_name(&other),
112                "MCP tool schema is not a JSON object; dropping it"
113            );
114            None
115        }
116        None => None,
117    }
118}
119
120fn schema_type_name(value: &Value) -> &'static str {
121    match value {
122        Value::Null => "null",
123        Value::Bool(_) => "bool",
124        Value::Number(_) => "number",
125        Value::String(_) => "string",
126        Value::Array(_) => "array",
127        Value::Object(_) => "object",
128    }
129}
130
131#[cfg(test)]
132#[path = "protocol_tests.rs"]
133mod tests;