Skip to main content

component_shape_mcp/
error.rs

1use super::*;
2
3/// Typed setup, decoding, validation, and handler error used by MCP helpers.
4#[derive(Clone, Debug, Eq, thiserror::Error, PartialEq)]
5pub enum McpToolError {
6    /// Tool arguments were omitted or supplied as a non-object JSON value.
7    #[error("tool arguments must be a JSON object")]
8    ArgumentsMustBeObject,
9    /// A required tool argument field is missing.
10    #[error("missing required field `{field}`")]
11    MissingField { field: String },
12    /// A non-nullable tool argument field was supplied as JSON null.
13    #[error("field `{field}` does not accept null")]
14    UnexpectedNull { field: String },
15    /// A field was supplied by multiple accepted wire names.
16    #[error("field `{field}` was provided more than once")]
17    DuplicateField { field: String },
18    /// A field could not be decoded into its typed Rust value.
19    #[error("failed to decode field `{field}`: {message}")]
20    DecodeField { field: String, message: String },
21    /// A raw argument field remains after typed decoding finishes.
22    #[error("unknown field `{field}`")]
23    UnknownField { field: String },
24    /// A field value is outside the accepted schema or enum values.
25    #[error("unknown value `{value}` for field `{field}`")]
26    InvalidFieldValue { field: String, value: String },
27    /// Validation rejected a value or metadata object.
28    #[error("validation failed: {message}")]
29    Validation {
30        message: String,
31        details: Vec<Value>,
32    },
33    /// A handler returned structured content that does not match its output schema.
34    #[error("tool `{name}` returned invalid structured content: {message}")]
35    InvalidToolOutput { name: String, message: String },
36    /// A value conversion failed.
37    #[error("conversion failed: {message}")]
38    Conversion { message: String },
39    /// A handler returned an application-level failure.
40    #[error("handler failed: {message}")]
41    Handler { message: String },
42    /// A schema or metadata definition is invalid.
43    #[error("invalid {label}: {message}")]
44    InvalidSchema { label: String, message: String },
45    /// A tool with the same name is already registered.
46    #[error("tool `{name}` is already registered")]
47    DuplicateTool { name: String },
48    /// A requested tool name is not registered.
49    #[error("unknown tool `{name}`")]
50    UnknownTool { name: String },
51    /// A resource with the same URI is already registered.
52    #[error("resource `{uri}` is already registered")]
53    DuplicateResource { uri: String },
54    /// A requested resource URI is not registered.
55    #[error("unknown resource `{uri}`")]
56    UnknownResource { uri: String },
57    /// A prompt with the same name is already registered.
58    #[error("prompt `{name}` is already registered")]
59    DuplicatePrompt { name: String },
60    /// A requested prompt name is not registered.
61    #[error("unknown prompt `{name}`")]
62    UnknownPrompt { name: String },
63}
64
65impl McpToolError {
66    /// Stable machine-readable error kind used in MCP structured error content.
67    pub const fn kind(&self) -> &'static str {
68        match self {
69            Self::ArgumentsMustBeObject => "arguments_must_be_object",
70            Self::MissingField { .. } => "missing_field",
71            Self::UnexpectedNull { .. } => "unexpected_null",
72            Self::DuplicateField { .. } => "duplicate_field",
73            Self::DecodeField { .. } => "decode_field",
74            Self::UnknownField { .. } => "unknown_field",
75            Self::InvalidFieldValue { .. } => "invalid_field_value",
76            Self::Validation { .. } => "validation",
77            Self::InvalidToolOutput { .. } => "invalid_tool_output",
78            Self::Conversion { .. } => "conversion",
79            Self::Handler { .. } => "handler",
80            Self::InvalidSchema { .. } => "invalid_schema",
81            Self::DuplicateTool { .. } => "duplicate_tool",
82            Self::UnknownTool { .. } => "unknown_tool",
83            Self::DuplicateResource { .. } => "duplicate_resource",
84            Self::UnknownResource { .. } => "unknown_resource",
85            Self::DuplicatePrompt { .. } => "duplicate_prompt",
86            Self::UnknownPrompt { .. } => "unknown_prompt",
87        }
88    }
89
90    /// Build the `structured_content.error` object for this typed MCP error.
91    pub fn to_structured_value(&self) -> Value {
92        let mut object = Map::new();
93        object.insert("kind".to_string(), json!(self.kind()));
94        object.insert("message".to_string(), json!(self.to_string()));
95
96        match self {
97            Self::ArgumentsMustBeObject => {},
98            Self::MissingField { field }
99            | Self::UnexpectedNull { field }
100            | Self::DuplicateField { field }
101            | Self::UnknownField { field } => {
102                object.insert("field".to_string(), json!(field));
103            },
104            Self::DecodeField { field, message } => {
105                object.insert("field".to_string(), json!(field));
106                object.insert("detail".to_string(), json!(message));
107            },
108            Self::InvalidFieldValue { field, value } => {
109                object.insert("field".to_string(), json!(field));
110                object.insert("value".to_string(), json!(value));
111            },
112            Self::Validation { message, details } => {
113                object.insert("detail".to_string(), json!(message));
114                if !details.is_empty() {
115                    object.insert("details".to_string(), json!(details));
116                }
117            },
118            Self::InvalidToolOutput { name, message } => {
119                object.insert("name".to_string(), json!(name));
120                object.insert("detail".to_string(), json!(message));
121            },
122            Self::Conversion { message } | Self::Handler { message } => {
123                object.insert("detail".to_string(), json!(message));
124            },
125            Self::InvalidSchema { label, message } => {
126                object.insert("label".to_string(), json!(label));
127                object.insert("detail".to_string(), json!(message));
128            },
129            Self::DuplicateTool { name } | Self::UnknownTool { name } => {
130                object.insert("name".to_string(), json!(name));
131            },
132            Self::DuplicateResource { uri } | Self::UnknownResource { uri } => {
133                object.insert("uri".to_string(), json!(uri));
134            },
135            Self::DuplicatePrompt { name } | Self::UnknownPrompt { name } => {
136                object.insert("name".to_string(), json!(name));
137            },
138        }
139
140        Value::Object(object)
141    }
142
143    /// Build a missing-field error.
144    pub fn missing_field(field: impl Into<String>) -> Self {
145        Self::MissingField {
146            field: field.into(),
147        }
148    }
149
150    /// Build a field decoding error.
151    pub fn decode(field: impl Into<String>, message: impl Into<String>) -> Self {
152        Self::DecodeField {
153            field: field.into(),
154            message: message.into(),
155        }
156    }
157
158    /// Build an invalid field value error.
159    pub fn invalid_field_value(field: impl Into<String>, value: impl Into<String>) -> Self {
160        Self::InvalidFieldValue {
161            field: field.into(),
162            value: value.into(),
163        }
164    }
165
166    /// Build a validation error with a text message.
167    pub fn validation(message: impl Into<String>) -> Self {
168        Self::Validation {
169            message: message.into(),
170            details: Vec::new(),
171        }
172    }
173
174    /// Build a validation error from text details.
175    pub fn validation_details(details: impl IntoIterator<Item = impl Into<String>>) -> Self {
176        let details = details.into_iter().map(Into::into).collect::<Vec<_>>();
177        Self::Validation {
178            message: details.join("; "),
179            details: details.into_iter().map(Value::String).collect(),
180        }
181    }
182
183    /// Build a validation error with machine-readable structured details.
184    pub fn validation_structured_details(
185        message: impl Into<String>,
186        details: impl IntoIterator<Item = Value>,
187    ) -> Self {
188        Self::Validation {
189            message: message.into(),
190            details: details.into_iter().collect(),
191        }
192    }
193
194    /// Build a conversion error.
195    pub fn conversion(message: impl Into<String>) -> Self {
196        Self::Conversion {
197            message: message.into(),
198        }
199    }
200
201    /// Build an invalid tool-output error.
202    pub fn invalid_tool_output(name: impl Into<String>, message: impl Into<String>) -> Self {
203        Self::InvalidToolOutput {
204            name: name.into(),
205            message: message.into(),
206        }
207    }
208
209    /// Build a handler error.
210    pub fn handler(message: impl Into<String>) -> Self {
211        Self::Handler {
212            message: message.into(),
213        }
214    }
215
216    /// Build an invalid schema or metadata error.
217    pub fn invalid_schema(label: impl Into<String>, message: impl Into<String>) -> Self {
218        Self::InvalidSchema {
219            label: label.into(),
220            message: message.into(),
221        }
222    }
223
224    /// Build a duplicate tool registration error.
225    pub fn duplicate_tool(name: impl Into<String>) -> Self {
226        Self::DuplicateTool { name: name.into() }
227    }
228
229    /// Build a duplicate resource registration error.
230    pub fn duplicate_resource(uri: impl Into<String>) -> Self {
231        Self::DuplicateResource { uri: uri.into() }
232    }
233
234    /// Build an unknown resource lookup error.
235    pub fn unknown_resource(uri: impl Into<String>) -> Self {
236        Self::UnknownResource { uri: uri.into() }
237    }
238
239    /// Build a duplicate prompt registration error.
240    pub fn duplicate_prompt(name: impl Into<String>) -> Self {
241        Self::DuplicatePrompt { name: name.into() }
242    }
243
244    /// Build an unknown prompt lookup error.
245    pub fn unknown_prompt(name: impl Into<String>) -> Self {
246        Self::UnknownPrompt { name: name.into() }
247    }
248}
249
250pub(crate) fn reject_unknown_arguments(arguments: McpToolArguments) -> Result<(), McpToolError> {
251    if let Some(field) = arguments.keys().next() {
252        return Err(McpToolError::UnknownField {
253            field: field.clone(),
254        });
255    }
256    Ok(())
257}
258
259pub(crate) fn validate_value_against_closed_schema(
260    field: &str,
261    schema: &Value,
262    value: &Value,
263) -> Result<(), McpToolError> {
264    if value.is_null() {
265        return Ok(());
266    }
267
268    let Value::Object(schema) = schema else {
269        return Ok(());
270    };
271
272    if let Some(object_value) = value.as_object() {
273        if let Some(schemas) = schema.get("anyOf").and_then(Value::as_array) {
274            validate_value_against_any_closed_schema(field, schemas, value)?;
275        }
276        if let Some(schemas) = schema.get("oneOf").and_then(Value::as_array) {
277            validate_value_against_any_closed_schema(field, schemas, value)?;
278        }
279        if let Some(schemas) = schema.get("allOf").and_then(Value::as_array) {
280            for schema in applicable_closed_schemas(schemas, value) {
281                validate_value_against_closed_schema(field, schema, value)?;
282            }
283        }
284        validate_closed_object_fields(field, schema, object_value)?;
285    } else if let Some(array_value) = value.as_array() {
286        if let Some(schemas) = schema.get("anyOf").and_then(Value::as_array) {
287            validate_value_against_any_closed_schema(field, schemas, value)?;
288        }
289        if let Some(items) = schema.get("items") {
290            for (index, item) in array_value.iter().enumerate() {
291                validate_value_against_closed_schema(&format!("{field}[{index}]"), items, item)?;
292            }
293        }
294        if let Some(items) = schema.get("prefixItems").and_then(Value::as_array) {
295            for (index, (schema, item)) in items.iter().zip(array_value).enumerate() {
296                validate_value_against_closed_schema(&format!("{field}[{index}]"), schema, item)?;
297            }
298        }
299    }
300
301    Ok(())
302}
303
304fn validate_value_against_any_closed_schema(
305    field: &str,
306    schemas: &[Value],
307    value: &Value,
308) -> Result<(), McpToolError> {
309    let mut first_error = None;
310    for schema in applicable_closed_schemas(schemas, value) {
311        match validate_value_against_closed_schema(field, schema, value) {
312            Ok(()) => return Ok(()),
313            Err(error) if first_error.is_none() => first_error = Some(error),
314            Err(_) => {},
315        }
316    }
317
318    first_error.map_or(Ok(()), Err)
319}
320
321fn applicable_closed_schemas<'a>(
322    schemas: &'a [Value],
323    value: &Value,
324) -> impl Iterator<Item = &'a Value> {
325    schemas
326        .iter()
327        .filter(move |schema| closed_schema_applies_to_value(schema, value))
328}
329
330fn closed_schema_applies_to_value(schema: &Value, value: &Value) -> bool {
331    let Value::Object(schema) = schema else {
332        return false;
333    };
334
335    if schema.contains_key("anyOf") || schema.contains_key("oneOf") || schema.contains_key("allOf")
336    {
337        return true;
338    }
339
340    match value {
341        Value::Object(_) => {
342            type_includes(schema, "object")
343                || schema.contains_key("properties")
344                || schema.contains_key("required")
345                || schema.contains_key("additionalProperties")
346        },
347        Value::Array(_) => {
348            type_includes(schema, "array")
349                || schema.contains_key("items")
350                || schema.contains_key("prefixItems")
351        },
352        _ => false,
353    }
354}
355
356pub(crate) fn type_includes(schema: &Map<String, Value>, expected: &str) -> bool {
357    match schema.get("type") {
358        Some(Value::String(value)) => value == expected,
359        Some(Value::Array(values)) => values
360            .iter()
361            .any(|value| matches!(value, Value::String(value) if value == expected)),
362        _ => false,
363    }
364}
365
366fn validate_closed_object_fields(
367    field: &str,
368    schema: &Map<String, Value>,
369    value: &Map<String, Value>,
370) -> Result<(), McpToolError> {
371    let mut wire_names = BTreeMap::new();
372    let properties = schema.get("properties").and_then(Value::as_object);
373    if let Some(properties) = properties {
374        for (property, property_schema) in properties {
375            wire_names.insert(property.as_str(), property.as_str());
376            if let Some(aliases) = property_schema
377                .get("x-mcpAliases")
378                .and_then(Value::as_array)
379            {
380                for alias in aliases.iter().filter_map(Value::as_str) {
381                    wire_names.insert(alias, property.as_str());
382                }
383            }
384        }
385    }
386
387    if let Some(required) = schema.get("required").and_then(Value::as_array) {
388        for required in required.iter().filter_map(Value::as_str) {
389            let present = if wire_names.is_empty() {
390                value.contains_key(required)
391            } else {
392                value.keys().any(|name| {
393                    wire_names
394                        .get(name.as_str())
395                        .is_some_and(|property| *property == required)
396                })
397            };
398            if !present {
399                return Err(McpToolError::MissingField {
400                    field: nested_field_name(field, required),
401                });
402            }
403        }
404    }
405
406    let rejects_additional = rejects_additional_properties(schema);
407    let additional_schema = additional_properties_schema(schema);
408    let mut seen = BTreeSet::new();
409    for (name, nested_value) in value {
410        let Some(property) = wire_names.get(name.as_str()).copied() else {
411            if rejects_additional {
412                return Err(McpToolError::UnknownField {
413                    field: nested_field_name(field, name),
414                });
415            }
416            if let Some(additional_schema) = additional_schema {
417                validate_value_against_closed_schema(
418                    &nested_field_name(field, name),
419                    additional_schema,
420                    nested_value,
421                )?;
422            }
423            continue;
424        };
425        if !seen.insert(property) {
426            return Err(McpToolError::DuplicateField {
427                field: nested_field_name(field, property),
428            });
429        }
430        if let Some(property_schema) = properties.and_then(|properties| properties.get(property)) {
431            validate_value_against_closed_schema(
432                &nested_field_name(field, property),
433                property_schema,
434                nested_value,
435            )?;
436        }
437    }
438
439    Ok(())
440}
441
442fn rejects_additional_properties(schema: &Map<String, Value>) -> bool {
443    schema
444        .get("additionalProperties")
445        .is_some_and(|value| matches!(value, Value::Bool(false)))
446}
447
448fn additional_properties_schema(schema: &Map<String, Value>) -> Option<&Value> {
449    match schema.get("additionalProperties") {
450        Some(Value::Bool(_)) | None => None,
451        Some(schema) => Some(schema),
452    }
453}
454
455fn nested_field_name(parent: &str, child: &str) -> String {
456    if parent.is_empty() {
457        child.to_string()
458    } else {
459        format!("{parent}.{child}")
460    }
461}
462
463pub(crate) fn normalize_value_against_schema(schema: &Value, value: Value) -> Value {
464    if schema_has_composite_keywords(schema) {
465        return normalize_with_composite_schema(schema, value);
466    }
467
468    let Value::Object(schema) = schema else {
469        return value;
470    };
471
472    match value {
473        Value::Object(value) => normalize_object_value_against_schema(schema, value),
474        Value::Array(value) => normalize_array_value_against_schema(schema, value),
475        Value::String(value) => normalize_string_value_against_schema(schema, value),
476        value => value,
477    }
478}
479
480fn schema_has_composite_keywords(schema: &Value) -> bool {
481    let Value::Object(schema) = schema else {
482        return false;
483    };
484    schema.contains_key("anyOf") || schema.contains_key("oneOf") || schema.contains_key("allOf")
485}
486
487fn normalize_with_composite_schema(schema: &Value, value: Value) -> Value {
488    let Value::Object(schema) = schema else {
489        return value;
490    };
491
492    for keyword in ["anyOf", "oneOf"] {
493        let Some(schemas) = schema.get(keyword).and_then(Value::as_array) else {
494            continue;
495        };
496        let Some(schema) = schemas
497            .iter()
498            .find(|schema| schema_applies_to_value_for_normalization(schema, &value))
499        else {
500            continue;
501        };
502        return normalize_value_against_schema(schema, value);
503    }
504
505    let Some(schemas) = schema.get("allOf").and_then(Value::as_array) else {
506        return value;
507    };
508    let mut value = value;
509    for schema in schemas {
510        if schema_applies_to_value_for_normalization(schema, &value) {
511            value = normalize_value_against_schema(schema, value);
512        }
513    }
514    value
515}
516
517fn schema_applies_to_value_for_normalization(schema: &Value, value: &Value) -> bool {
518    let Value::Object(schema_object) = schema else {
519        return matches!(schema, Value::Bool(true));
520    };
521
522    if schema_object.is_empty() {
523        return true;
524    }
525    if schema_object.contains_key("anyOf")
526        || schema_object.contains_key("oneOf")
527        || schema_object.contains_key("allOf")
528    {
529        return true;
530    }
531
532    match value {
533        Value::Null => value_schema_allows_null(schema),
534        Value::Bool(_) => type_includes(schema_object, "boolean"),
535        Value::Number(number) if number.is_i64() || number.is_u64() => {
536            type_includes(schema_object, "integer") || type_includes(schema_object, "number")
537        },
538        Value::Number(_) => type_includes(schema_object, "number"),
539        Value::String(value) => {
540            type_includes(schema_object, "string")
541                || schema_object
542                    .get("enum")
543                    .and_then(Value::as_array)
544                    .is_some_and(|values| {
545                        values
546                            .iter()
547                            .any(|candidate| matches!(candidate, Value::String(candidate) if candidate == value))
548                    })
549                || enum_decode_alias_target(schema_object, value).is_some()
550        },
551        Value::Array(_) | Value::Object(_) => closed_schema_applies_to_value(schema, value),
552    }
553}
554
555fn normalize_object_value_against_schema(
556    schema: &Map<String, Value>,
557    value: Map<String, Value>,
558) -> Value {
559    let mut wire_names = BTreeMap::new();
560    let properties = schema.get("properties").and_then(Value::as_object);
561    if let Some(properties) = properties {
562        for (property, property_schema) in properties {
563            wire_names.insert(property.as_str(), property.as_str());
564            if let Some(aliases) = property_schema
565                .get("x-mcpAliases")
566                .and_then(Value::as_array)
567            {
568                for alias in aliases.iter().filter_map(Value::as_str) {
569                    wire_names.insert(alias, property.as_str());
570                }
571            }
572        }
573    }
574
575    let additional_schema = additional_properties_schema(schema);
576    let mut normalized = Map::new();
577    for (name, nested_value) in value {
578        let Some(property) = wire_names.get(name.as_str()).copied() else {
579            let nested_value = match additional_schema {
580                Some(schema) => normalize_value_against_schema(schema, nested_value),
581                None => nested_value,
582            };
583            normalized.insert(name, nested_value);
584            continue;
585        };
586
587        let property_schema = properties
588            .and_then(|properties| properties.get(property))
589            .expect("wire name should refer to an existing schema property");
590        let decode_name = property_schema
591            .get("x-mcpDecodeName")
592            .and_then(Value::as_str)
593            .unwrap_or(property);
594        normalized.insert(
595            decode_name.to_string(),
596            normalize_value_against_schema(property_schema, nested_value),
597        );
598    }
599
600    Value::Object(normalized)
601}
602
603fn normalize_array_value_against_schema(schema: &Map<String, Value>, value: Vec<Value>) -> Value {
604    if let Some(items) = schema.get("items") {
605        return Value::Array(
606            value
607                .into_iter()
608                .map(|item| normalize_value_against_schema(items, item))
609                .collect(),
610        );
611    }
612
613    let Some(prefix_items) = schema.get("prefixItems").and_then(Value::as_array) else {
614        return Value::Array(value);
615    };
616
617    Value::Array(
618        value
619            .into_iter()
620            .enumerate()
621            .map(|(index, item)| match prefix_items.get(index) {
622                Some(schema) => normalize_value_against_schema(schema, item),
623                None => item,
624            })
625            .collect(),
626    )
627}
628
629fn normalize_string_value_against_schema(schema: &Map<String, Value>, value: String) -> Value {
630    match enum_decode_alias_target(schema, &value) {
631        Some(target) => Value::String(target.to_string()),
632        None => Value::String(value),
633    }
634}
635
636fn enum_decode_alias_target<'a>(schema: &'a Map<String, Value>, value: &str) -> Option<&'a str> {
637    schema
638        .get("x-mcpEnumDecodeAliases")
639        .and_then(Value::as_object)
640        .and_then(|aliases| aliases.get(value))
641        .and_then(Value::as_str)
642}