Skip to main content

a2a_protocol_server/handler/
input_modes.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Tom F. <tomf@tomtomtech.net> (https://github.com/tomtom215)
3
4//! Refusing message parts whose media type the agent card does not declare.
5//!
6//! Spec §3.1.1 lists `ContentTypeNotSupportedError` among `SendMessage`'s
7//! errors: "A Media Type provided in the request's message parts is not
8//! supported by the agent." What the agent supports is what its card
9//! declares — `defaultInputModes`, and each skill's `inputModes`. The ACTS
10//! conformance suite checks it as a MUST (CORE-SEND-004); until 2026-09-25
11//! this server accepted any media type and left the executor to cope.
12//!
13//! The rules, all chosen to refuse only what the card plainly excludes:
14//!
15//! - Only a part that carries an explicit `mediaType` is checked. A text
16//!   part without one is not assumed to be `text/plain`, and a data part
17//!   without one is not assumed to be `application/json`.
18//! - The accepted set is `defaultInputModes` together with every skill's
19//!   `inputModes`: a message does not name the skill it is for, so a mode
20//!   any skill declares is a mode the agent accepts.
21//! - Nothing is enforced without a card, or when the card declares no modes
22//!   at all: an empty declaration says nothing, rather than "nothing".
23//! - Media types compare case-insensitively on their essence, ignoring
24//!   parameters (`text/plain; charset=utf-8` is `text/plain`); a declared
25//!   `*/*` or `type/*` matches as a wildcard.
26//!
27//! [`RequestHandlerBuilder::allow_undeclared_input_modes`](crate::builder::RequestHandlerBuilder::allow_undeclared_input_modes)
28//! turns the check off, for an agent whose card under-declares what it
29//! accepts.
30
31use a2a_protocol_types::agent_card::AgentCard;
32use a2a_protocol_types::error::A2aError;
33use a2a_protocol_types::message::Message;
34
35use super::RequestHandler;
36use crate::error::{ServerError, ServerResult};
37
38/// The media types a card accepts, or `None` when nothing is to be enforced.
39pub fn accepted_input_modes(
40    card: Option<&AgentCard>,
41    allow_undeclared: bool,
42) -> Option<Vec<String>> {
43    if allow_undeclared {
44        return None;
45    }
46    let card = card?;
47    let mut modes: Vec<String> = card
48        .default_input_modes
49        .iter()
50        .chain(
51            card.skills
52                .iter()
53                .flat_map(|s| s.input_modes.iter().flatten()),
54        )
55        .map(|m| essence(m))
56        .filter(|m| !m.is_empty())
57        .collect();
58    modes.sort();
59    modes.dedup();
60    (!modes.is_empty()).then_some(modes)
61}
62
63/// A media type's essence: lower-cased, parameters and whitespace removed.
64fn essence(media_type: &str) -> String {
65    media_type
66        .split(';')
67        .next()
68        .unwrap_or_default()
69        .trim()
70        .to_ascii_lowercase()
71}
72
73/// Whether a declared mode (possibly a wildcard) admits `actual`, both
74/// already reduced to their essence.
75fn admits(declared: &str, actual: &str) -> bool {
76    if declared == "*/*" || declared == actual {
77        return true;
78    }
79    declared
80        .strip_suffix("/*")
81        .is_some_and(|ty| actual.split('/').next() == Some(ty))
82}
83
84impl RequestHandler {
85    /// Refuses a message with a part whose explicit media type the card does
86    /// not declare.
87    ///
88    /// # Errors
89    ///
90    /// `ContentTypeNotSupportedError`, naming the part and its media type.
91    pub fn ensure_input_modes_supported(&self, message: &Message) -> ServerResult<()> {
92        let Some(accepted) = &self.accepted_input_modes else {
93            return Ok(());
94        };
95        for (i, part) in message.parts.iter().enumerate() {
96            let Some(media_type) = part.media_type.as_deref() else {
97                continue;
98            };
99            let actual = essence(media_type);
100            if !accepted.iter().any(|declared| admits(declared, &actual)) {
101                return Err(ServerError::Protocol(A2aError::content_type_not_supported(
102                    format!(
103                        "message part {i} has media type {media_type:?}, which the agent \
104                         card does not declare (defaultInputModes or a skill's inputModes)"
105                    ),
106                )));
107            }
108        }
109        Ok(())
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use a2a_protocol_types::agent_card::{
116        AgentCapabilities, AgentCard, AgentInterface, AgentSkill,
117    };
118    use a2a_protocol_types::error::ErrorCode;
119    use a2a_protocol_types::message::{Message, Part};
120
121    use crate::agent_executor;
122    use crate::builder::RequestHandlerBuilder;
123
124    struct DummyExecutor;
125    agent_executor!(DummyExecutor, |_ctx, _queue| async { Ok(()) });
126
127    fn card(default: &[&str], skill: Option<&[&str]>) -> AgentCard {
128        let mut s = AgentSkill::new("s", "S", "a skill");
129        if let Some(modes) = skill {
130            s = s.with_input_modes(modes.iter().copied());
131        }
132        AgentCard {
133            url: None,
134            name: "Test Agent".into(),
135            description: "A test agent".into(),
136            version: "1.0.0".into(),
137            supported_interfaces: vec![AgentInterface {
138                url: "http://localhost:8080".into(),
139                protocol_binding: "JSONRPC".into(),
140                protocol_version: "1.0.0".into(),
141                tenant: None,
142            }],
143            default_input_modes: default.iter().map(|m| (*m).to_owned()).collect(),
144            default_output_modes: vec!["text/plain".into()],
145            skills: vec![s],
146            capabilities: AgentCapabilities::none(),
147            provider: None,
148            icon_url: None,
149            documentation_url: None,
150            security_schemes: None,
151            security_requirements: None,
152            signatures: None,
153        }
154    }
155
156    fn with_part(media_type: Option<&str>) -> Message {
157        let mut part = Part::text("hi");
158        part.media_type = media_type.map(str::to_owned);
159        Message::user("m-1", vec![part])
160    }
161
162    /// `Ok`, or the error's code and message.
163    fn check(
164        card: Option<AgentCard>,
165        opt_out: bool,
166        media_type: Option<&str>,
167    ) -> Result<(), (ErrorCode, String)> {
168        let mut builder = RequestHandlerBuilder::new(DummyExecutor);
169        if let Some(card) = card {
170            builder = builder.with_agent_card(card);
171        }
172        if opt_out {
173            builder = builder.allow_undeclared_input_modes();
174        }
175        let handler = builder.build().expect("handler");
176        handler
177            .ensure_input_modes_supported(&with_part(media_type))
178            .map_err(|e| {
179                let e = e.to_a2a_error();
180                (e.code, e.message)
181            })
182    }
183
184    #[test]
185    fn a_declared_media_type_is_accepted() {
186        let card = card(&["application/json"], None);
187        assert!(check(Some(card), false, Some("application/json")).is_ok());
188    }
189
190    #[test]
191    fn an_undeclared_media_type_is_refused_as_content_type_not_supported() {
192        let card = card(&["text/plain"], None);
193        let (code, message) =
194            check(Some(card), false, Some("application/x-unsupported")).unwrap_err();
195        assert_eq!(code, ErrorCode::ContentTypeNotSupported);
196        assert!(message.contains("application/x-unsupported"), "{message}");
197    }
198
199    #[test]
200    fn a_mode_only_a_skill_declares_is_accepted() {
201        let card = card(&["text/plain"], Some(&["image/png"]));
202        assert!(check(Some(card), false, Some("image/png")).is_ok());
203    }
204
205    #[test]
206    fn parameters_and_case_are_ignored() {
207        let card = card(&["Text/Plain"], None);
208        assert!(check(Some(card), false, Some("text/plain; charset=utf-8")).is_ok());
209    }
210
211    #[test]
212    fn wildcards_admit_their_range_and_nothing_else() {
213        assert!(check(Some(card(&["image/*"], None)), false, Some("image/png")).is_ok());
214        assert!(check(Some(card(&["image/*"], None)), false, Some("imagex/png")).is_err());
215        assert!(check(Some(card(&["*/*"], None)), false, Some("application/x-any")).is_ok());
216    }
217
218    #[test]
219    fn a_part_without_a_media_type_is_not_checked() {
220        assert!(check(Some(card(&["application/json"], None)), false, None).is_ok());
221    }
222
223    #[test]
224    fn nothing_is_enforced_without_a_card_or_a_declaration_or_when_opted_out() {
225        assert!(check(None, false, Some("application/x-unsupported")).is_ok());
226        assert!(
227            check(
228                Some(card(&[], None)),
229                false,
230                Some("application/x-unsupported")
231            )
232            .is_ok()
233        );
234        assert!(
235            check(
236                Some(card(&["text/plain"], None)),
237                true,
238                Some("application/x-unsupported")
239            )
240            .is_ok()
241        );
242    }
243}