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}