Skip to main content

protocol_droid/adapters/secondary/a2a/
method_table.rs

1//! The A2A method table — the one place a JSON-RPC method name is written down
2//! for the A2A protocol.
3//!
4//! # Why this file exists at all
5//!
6//! [`MethodTable`] is
7//! protocol-neutral so that MCP, A2A, and ACP can each add themselves without
8//! touching it. The cost of that neutrality is that some module has to hold
9//! the actual A2A method names, and this is it. It is deliberately small and
10//! deliberately boring: a `match` on the name, and a variant per method.
11//!
12//! # The `match` is the perfect hash
13//!
14//! The task called for a perfect-hash or `match` dispatch. A `match` on string
15//! literals is the better of the two for a fixed vocabulary of this size: rustc
16//! lowers it to a switch on the length and then a chain of memcmps, which is
17//! allocation-free and has no build step. A perfect hash would have to be
18//! generated and checked in, and would then be a second thing that can
19//! disagree with the list of methods.
20//!
21//! # Params decoding stops here
22//!
23//! Decoding a method's params into a per-revision wire type needs the
24//! negotiated revision, which the session owns. This table proves the method
25//! exists and hands back the raw params, which is exactly the split between
26//! "route" and "dispatch". T27 follow-up replaces the `Ok(params)` body with
27//! a per-revision `Wire` decode.
28//!
29//! # Wire form: v1.0 `PascalCase`, not slash-separated
30//!
31//! A2A v1.0 names its JSON-RPC methods in `PascalCase` — `SendMessage`,
32//! `GetTask`, `CancelTask` — not the slash-separated `message/send`,
33//! `tasks/get` form the earlier 0.2.x / 0.3.x revisions used. The method
34//! names are also the gRPC method names; the JSON-RPC binding is the same
35//! surface. The spec explicitly states this is so JSON-RPC and gRPC peers
36//! can name the same method identically:
37//!
38//! | Functionality | JSON-RPC Method | gRPC Method |
39//! | --- | --- | --- |
40//! | Send message | `SendMessage` | `SendMessage` |
41//! | Get task | `GetTask` | `GetTask` |
42//!
43//! The slash-separated form (e.g. `message/send`) is a 0.3.x artefact, kept
44//! here for the bridge (T28) but not on the v1.0 wire.
45//!
46//! Source: A2A v1.0 specification, §5.3 "Method Mapping Reference". Vendored
47//! at `crates/protocol-droid-schema/spec/a2a/v1.0/a2a.proto` (the proto is
48//! the normative source; the JSON Schema is a generated build artifact).
49
50use serde_json::value::RawValue;
51
52use crate::application::jsonrpc::{MethodTable, RouteError};
53
54/// Every method A2A defines, as of `v1.0`.
55///
56/// One variant per method, so an unknown name cannot be confused with a known
57/// one at the type level: `lookup` returning `None` is the only way to reach
58/// `-32601`.
59///
60/// A2A has no notifications at `v1.0`; every method is a request a peer
61/// answers. The wire surface is request-only.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
63pub enum A2aMethod {
64    /// `SendMessage` — send a message to an agent.
65    SendMessage,
66    /// `SendStreamingMessage` — streaming version of `SendMessage`.
67    SendStreamingMessage,
68    /// `GetTask` — get the latest state of a task.
69    GetTask,
70    /// `ListTasks` — list tasks with optional filtering and pagination.
71    ListTasks,
72    /// `CancelTask` — cancel a task in progress.
73    CancelTask,
74    /// `SubscribeToTask` — subscribe to task updates for a non-terminal task.
75    SubscribeToTask,
76    /// `CreateTaskPushNotificationConfig` — register a push notification
77    /// config for a task. The webhook URL on this config is the SSRF
78    /// surface T27's push-notification follow-up has to filter.
79    CreateTaskPushNotificationConfig,
80    /// `GetTaskPushNotificationConfig` — fetch a push notification config.
81    GetTaskPushNotificationConfig,
82    /// `ListTaskPushNotificationConfigs` — list push notification configs.
83    ListTaskPushNotificationConfigs,
84    /// `DeleteTaskPushNotificationConfig` — remove a push notification
85    /// config.
86    DeleteTaskPushNotificationConfig,
87    /// `GetExtendedAgentCard` — fetch the authenticated extended agent card.
88    GetExtendedAgentCard,
89}
90
91impl A2aMethod {
92    /// Every method, in the order the specification introduces them.
93    ///
94    /// Exists so a test can assert the direction a per-name test cannot: that
95    /// every *variant* is reachable from a name, not merely that every name
96    /// has a variant. A method added to the enum and forgotten in `lookup`
97    /// is otherwise a `-32601` waiting to happen.
98    pub const ALL: [Self; 11] = [
99        Self::SendMessage,
100        Self::SendStreamingMessage,
101        Self::GetTask,
102        Self::ListTasks,
103        Self::CancelTask,
104        Self::SubscribeToTask,
105        Self::CreateTaskPushNotificationConfig,
106        Self::GetTaskPushNotificationConfig,
107        Self::ListTaskPushNotificationConfigs,
108        Self::DeleteTaskPushNotificationConfig,
109        Self::GetExtendedAgentCard,
110    ];
111
112    /// The wire name of this method, as the v1.0 specification defines it.
113    ///
114    /// `PascalCase` to match the proto file and the gRPC service. The
115    /// 0.2.x / 0.3.x slash-separated form (`message/send`, `tasks/get`) is
116    /// not reachable from this method, by design — the bridge (T28) is
117    /// where the form mismatch is resolved.
118    #[must_use]
119    pub const fn as_str(self) -> &'static str {
120        match self {
121            Self::SendMessage => "SendMessage",
122            Self::SendStreamingMessage => "SendStreamingMessage",
123            Self::GetTask => "GetTask",
124            Self::ListTasks => "ListTasks",
125            Self::CancelTask => "CancelTask",
126            Self::SubscribeToTask => "SubscribeToTask",
127            Self::CreateTaskPushNotificationConfig => "CreateTaskPushNotificationConfig",
128            Self::GetTaskPushNotificationConfig => "GetTaskPushNotificationConfig",
129            Self::ListTaskPushNotificationConfigs => "ListTaskPushNotificationConfigs",
130            Self::DeleteTaskPushNotificationConfig => "DeleteTaskPushNotificationConfig",
131            Self::GetExtendedAgentCard => "GetExtendedAgentCard",
132        }
133    }
134}
135
136/// The A2A implementation of [`MethodTable`].
137///
138/// A unit struct: the table is a fixed vocabulary with no configuration, so a
139/// value of it is a constant rather than something to build. Mirrors
140/// [`McpMethodTable`](crate::adapters::secondary::mcp::McpMethodTable).
141#[derive(Debug, Clone, Copy, Default)]
142pub struct A2aMethodTable;
143
144impl A2aMethodTable {
145    /// The shared table.
146    pub const TABLE: Self = Self;
147}
148
149impl MethodTable for A2aMethodTable {
150    type MethodId = A2aMethod;
151    type RequestParams<'a> = Option<&'a RawValue>;
152    type NotificationParams<'a> = Option<&'a RawValue>;
153
154    fn lookup(&self, name: &str) -> Option<A2aMethod> {
155        match name {
156            "SendMessage" => Some(A2aMethod::SendMessage),
157            "SendStreamingMessage" => Some(A2aMethod::SendStreamingMessage),
158            "GetTask" => Some(A2aMethod::GetTask),
159            "ListTasks" => Some(A2aMethod::ListTasks),
160            "CancelTask" => Some(A2aMethod::CancelTask),
161            "SubscribeToTask" => Some(A2aMethod::SubscribeToTask),
162            "CreateTaskPushNotificationConfig" => Some(A2aMethod::CreateTaskPushNotificationConfig),
163            "GetTaskPushNotificationConfig" => Some(A2aMethod::GetTaskPushNotificationConfig),
164            "ListTaskPushNotificationConfigs" => Some(A2aMethod::ListTaskPushNotificationConfigs),
165            "DeleteTaskPushNotificationConfig" => Some(A2aMethod::DeleteTaskPushNotificationConfig),
166            "GetExtendedAgentCard" => Some(A2aMethod::GetExtendedAgentCard),
167            _ => None,
168        }
169    }
170
171    fn decode_request_params<'a>(
172        &self,
173        method: A2aMethod,
174        params: Option<&'a RawValue>,
175    ) -> Result<Self::RequestParams<'a>, RouteError<'a>> {
176        // `params` is optional on the wire for every A2A method, and the
177        // difference between "absent" and "null" is not one any handler
178        // cares about, so both arrive here as `None`-shaped values. T27
179        // follow-up replaces this with a per-revision `Wire` decode gated
180        // on `A2aVersion`.
181        let _ = method;
182        Ok(params)
183    }
184
185    fn decode_notification_params<'a>(
186        &self,
187        method: A2aMethod,
188        params: Option<&'a RawValue>,
189    ) -> Result<Self::NotificationParams<'a>, RouteError<'a>> {
190        // A2A v1.0 has no notifications, so reaching here means the caller
191        // named a request method in a notification-shaped frame. The router
192        // answers with whatever the wire-shape check returns, not a per-
193        // method check; this branch is symmetric to the MCP one.
194        let _ = method;
195        Ok(params)
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    // Test code unwraps deliberately: the strict profile exists to keep
202    // production paths fallible, and a test that cannot unwrap cannot assert.
203    // The messages name the failing precondition, which is the useful part.
204    #![expect(
205        clippy::expect_used,
206        clippy::panic,
207        reason = "tests assert on fallible calls and panic with the failing precondition; propagating errors would replace a clear failure message with noise"
208    )]
209    //! The A2A table, exercised through the protocol-neutral router.
210    //!
211    //! Mirrors the MCP table tests in shape — the architecture depends on
212    //! both tables being reachable from inside the crate or the lib test
213    //! target will not build, and the dependency pointing in only the
214    //! direction the architecture allows (an adapter may name application,
215    //! application may not name an adapter) is a stronger test than a mock.
216    //!
217    //! The router's own behaviour, with a stand-in table, is tested in
218    //! `application/jsonrpc/router.rs`; the batch policy in
219    //! `application/jsonrpc/batch.rs`.
220
221    use assert_matches::assert_matches;
222    use serde_json::value::RawValue;
223
224    use crate::application::jsonrpc::{MethodTable, RouteError, Routed, route_one};
225    use crate::domain::jsonrpc::{Frame, decode};
226
227    use super::{A2aMethod, A2aMethodTable};
228
229    type TestError = Box<dyn std::error::Error>;
230
231    /// The one non-A2A method name, to prove `-32601` is the table's answer
232    /// and not a special case in the router.
233    const NOT_A2A: &str = "does/not/exist";
234
235    /// The A2A 0.3.x slash-form of `SendMessage`, included to prove the v1.0
236    /// table rejects it. A bridge to a 0.3.x peer translates the form before
237    /// this table ever sees a frame; the table itself is form-stable.
238    const LEGACY_A2A: &str = "message/send";
239
240    fn route(
241        input: &[u8],
242    ) -> Result<Routed<'_, A2aMethod, Option<&RawValue>, Option<&RawValue>>, RouteError<'_>> {
243        let envelope = match decode(input)? {
244            Frame::Single(envelope) => envelope,
245            // `BatchNotAllowed` is the wrong story for a single-message test,
246            // but it is the only refusal that does not lie about why: the
247            // router refuses a batch before it looks at any of its members.
248            Frame::Batch(_) => return Err(RouteError::BatchNotAllowed),
249        };
250        route_one(envelope, &A2aMethodTable::TABLE)
251    }
252
253    fn request(method: &str) -> String {
254        format!(r#"{{"jsonrpc":"2.0","id":1,"method":"{method}"}}"#)
255    }
256
257    /// Every method the v1.0 specification defines, as its wire name. A
258    /// method that exists in the enum but is missing here fails the
259    /// `every_variant_is_in_the_list` test, and a name that is listed but
260    /// not in the enum fails to compile.
261    const EVERY_METHOD: &[(&str, A2aMethod)] = &[
262        ("SendMessage", A2aMethod::SendMessage),
263        ("SendStreamingMessage", A2aMethod::SendStreamingMessage),
264        ("GetTask", A2aMethod::GetTask),
265        ("ListTasks", A2aMethod::ListTasks),
266        ("CancelTask", A2aMethod::CancelTask),
267        ("SubscribeToTask", A2aMethod::SubscribeToTask),
268        (
269            "CreateTaskPushNotificationConfig",
270            A2aMethod::CreateTaskPushNotificationConfig,
271        ),
272        (
273            "GetTaskPushNotificationConfig",
274            A2aMethod::GetTaskPushNotificationConfig,
275        ),
276        (
277            "ListTaskPushNotificationConfigs",
278            A2aMethod::ListTaskPushNotificationConfigs,
279        ),
280        (
281            "DeleteTaskPushNotificationConfig",
282            A2aMethod::DeleteTaskPushNotificationConfig,
283        ),
284        ("GetExtendedAgentCard", A2aMethod::GetExtendedAgentCard),
285    ];
286
287    #[test]
288    fn every_method_is_reachable_and_agrees_about_its_wire_name() {
289        for (name, expected) in EVERY_METHOD {
290            let found = A2aMethodTable::TABLE
291                .lookup(name)
292                .unwrap_or_else(|| panic!("{name} is in the table and must be found"));
293            assert_eq!(found, *expected, "{name} must resolve to its own variant");
294            assert_eq!(
295                found.as_str(),
296                *name,
297                "as_str must be the name lookup accepts, or a reply echoes the wrong method"
298            );
299        }
300    }
301
302    #[test]
303    fn every_variant_is_in_the_list() {
304        for method in A2aMethod::ALL {
305            assert!(
306                EVERY_METHOD.iter().any(|(_, listed)| *listed == method),
307                "{method:?} is a variant but no test names it, so nothing would fail if \
308                 lookup stopped resolving it"
309            );
310        }
311    }
312
313    #[test]
314    fn an_unknown_method_is_method_not_found() {
315        let input = request(NOT_A2A);
316        let error = route(input.as_bytes()).expect_err("no such A2A method");
317        assert_eq!(error.code(), crate::domain::ir::IrErrorCode::MethodNotFound);
318        assert_matches!(error, RouteError::MethodNotFound(name) if name == NOT_A2A);
319    }
320
321    #[test]
322    fn a_method_name_is_not_normalised() {
323        // The table is a `match`, not a lookup table, so a near-miss must
324        // miss. A2A v1.0 names methods PascalCase and the specification is
325        // explicit that this is case-sensitive, just like gRPC.
326        for near in [
327            "sendmessage",
328            "sendMessage",
329            "send_message",
330            " SendMessage",
331            "SendMessage ",
332        ] {
333            let input = request(near);
334            let error = route(input.as_bytes())
335                .expect_err("the specification names methods case-sensitively");
336            assert_eq!(
337                error.code(),
338                crate::domain::ir::IrErrorCode::MethodNotFound,
339                "{near} is not a method name"
340            );
341        }
342    }
343
344    #[test]
345    fn the_legacy_0_3_x_slash_form_is_not_a_v1_0_method() {
346        // The 0.3.x form is a different protocol surface. A bridge to a
347        // 0.3.x peer translates the form before this table ever sees it;
348        // the table itself is form-stable.
349        let input = request(LEGACY_A2A);
350        let error = route(input.as_bytes())
351            .expect_err("0.3.x slash-form must not resolve on the v1.0 table");
352        assert_eq!(error.code(), crate::domain::ir::IrErrorCode::MethodNotFound);
353        assert_matches!(error, RouteError::MethodNotFound(name) if name == LEGACY_A2A);
354    }
355
356    #[test]
357    fn a_request_routes_to_its_method_and_keeps_its_params_borrowed() -> Result<(), TestError> {
358        let input = br#"{"jsonrpc":"2.0","id":7,"method":"SendMessage","params":{"message":{"role":"user","parts":[]}}}"#;
359        match route(input) {
360            Ok(Routed::Request {
361                method, id, params, ..
362            }) => {
363                assert_eq!(method, A2aMethod::SendMessage);
364                assert_eq!(id.as_number(), Some(7));
365                let raw = params.ok_or("SendMessage has params")?;
366                assert_eq!(raw.get(), r#"{"message":{"role":"user","parts":[]}}"#);
367            }
368            other => return Err(format!("expected a request, got {other:?}").into()),
369        }
370        Ok(())
371    }
372
373    #[test]
374    fn a_request_without_params_routes_with_none() -> Result<(), TestError> {
375        // `GetTask` is a path-parameter-only request; A2A encodes the path
376        // parameter (`id`) in `params.id` per the proto, not in the URL
377        // path. The transport parser leaves the path parameter extraction
378        // for the dispatch arm; the table is only responsible for naming
379        // the method.
380        let input = br#"{"jsonrpc":"2.0","id":2,"method":"GetTask","params":{"id":"task-1"}}"#;
381        match route(input) {
382            Ok(Routed::Request { method, params, .. }) => {
383                assert_eq!(method, A2aMethod::GetTask);
384                let raw = params.ok_or("GetTask has params")?;
385                assert_eq!(raw.get(), r#"{"id":"task-1"}"#);
386            }
387            other => return Err(format!("expected a request, got {other:?}").into()),
388        }
389        Ok(())
390    }
391}