protocol_droid/application/jsonrpc/router.rs
1//! Turning a decoded envelope into a method to dispatch, without naming one.
2//!
3//! # The trait is the seam, and it is monomorphised on purpose
4//!
5//! [`MethodTable`] knows a method's *name* and can turn its raw params into a
6//! typed value, but it is declared by the consumer and implemented by whichever
7//! protocol module is in play. Nothing here mentions `tools/call` or
8//! `initialize`; that is the whole point of the split, and it is what lets T27
9//! and T29 add A2A and ACP without touching a line of this file.
10//!
11//! The trait is generic over its own `MethodId` and its own decoded params
12//! rather than erasing them to `Box<dyn Debug>`. Erasing would put a heap
13//! allocation on the path this whole layer exists to keep allocation-free, and
14//! it would throw away the type the caller needed before anything could be
15//! dispatched. The associated types are what "consumed through `impl
16//! MethodTable` so it monomorphises" means in practice.
17//!
18//! # Three type parameters, because a reply carries nothing
19//!
20//! [`Routed`] has one type parameter per arm that holds a value: the request
21//! params, the notification params, and nothing for the reply. A single
22//! parameter would mean either erasing the two params types into one or
23//! manufacturing a value the reply arm never reads, and both are worse than
24//! carrying the third type. It is the same argument as not erasing to
25//! `Box<dyn Debug>`, applied to the reply case.
26//!
27//! # Where an error is raised
28//!
29//! A method that is not in the table is `-32601` and a params shape that does
30//! not fit is `-32602` — both decided here, from the same code, whether the
31//! table is MCP's or A2A's. The message is the only part a protocol supplies.
32//! That is the difference between "route" and "dispatch", and keeping the seam
33//! between them is what lets a later task fill in the second half without
34//! rewriting the first.
35
36use serde_json::value::RawValue;
37
38use crate::domain::feature::FeatureProfile;
39use crate::domain::ir::IrErrorCode;
40use crate::domain::jsonrpc::{Envelope, MessageKind, Method, Outcome, WireId, decode};
41
42use super::BatchPolicy;
43
44/// What a routed frame contains, for one table.
45///
46/// A reply carries no decoded params, which is the whole reason [`Routed`] has
47/// three type parameters rather than one; naming the pair here keeps the two
48/// public signatures readable rather than wrapping across four lines.
49pub type RoutedFrame<'a, M> = Vec<RoutedMessage<'a, M>>;
50
51/// The same, for a single message.
52pub type RoutedMessage<'a, M> = Routed<
53 'a,
54 <M as MethodTable>::MethodId,
55 <M as MethodTable>::RequestParams<'a>,
56 <M as MethodTable>::NotificationParams<'a>,
57>;
58
59/// The set of methods one protocol speaks, and how to decode each one's params.
60///
61/// # Implementing this
62///
63/// A protocol module implements it once. [`lookup`](Self::lookup) is the
64/// dispatch table and should be a `match` on the method name — the compiler
65/// turns a `match` on string literals into a length switch followed by a memcmp
66/// chain, which beats a hash table for a small, fixed vocabulary and allocates
67/// nothing.
68///
69/// The two `decode_*_params` methods are separate rather than one method with an
70/// `is_request` flag because a request and a notification of the same method
71/// legitimately have different params, and a flag would push that decision out
72/// to every call site.
73///
74/// ```
75/// use protocol_droid::application::jsonrpc::{MethodTable, RouteError};
76/// use serde_json::value::RawValue;
77///
78/// #[derive(Debug, Clone, Copy, PartialEq, Eq)]
79/// enum MyMethod {
80/// Ping,
81/// }
82///
83/// struct MyTable;
84///
85/// impl MethodTable for MyTable {
86/// type MethodId = MyMethod;
87/// type RequestParams<'a> = ();
88/// type NotificationParams<'a> = ();
89///
90/// fn lookup(&self, name: &str) -> Option<MyMethod> {
91/// match name {
92/// "ping" => Some(MyMethod::Ping),
93/// _ => None,
94/// }
95/// }
96///
97/// fn decode_request_params<'a>(
98/// &self,
99/// _method: MyMethod,
100/// _params: Option<&'a RawValue>,
101/// ) -> Result<(), RouteError<'a>> {
102/// Ok(())
103/// }
104///
105/// fn decode_notification_params<'a>(
106/// &self,
107/// _method: MyMethod,
108/// _params: Option<&'a RawValue>,
109/// ) -> Result<(), RouteError<'a>> {
110/// Ok(())
111/// }
112/// }
113/// ```
114pub trait MethodTable {
115 /// How this table names the methods it supports.
116 type MethodId: Copy + std::fmt::Debug + PartialEq;
117
118 /// The decoded params of a request, which are method- and revision-specific.
119 ///
120 /// A GAT rather than a plain associated type because the decoded value
121 /// borrows the frame buffer. A protocol whose request params reference the
122 /// id, or slice the raw JSON, would otherwise be forced to own a copy of
123 /// data that is still alive.
124 type RequestParams<'a>;
125
126 /// The decoded params of a notification.
127 type NotificationParams<'a>;
128
129 /// The method a name denotes, or `None` if this protocol has no such
130 /// method — which the router answers with `-32601`.
131 fn lookup(&self, name: &str) -> Option<Self::MethodId>;
132
133 /// Decodes a request's params.
134 ///
135 /// # Errors
136 ///
137 /// [`RouteError::InvalidParams`] if `params` does not fit the method.
138 fn decode_request_params<'a>(
139 &self,
140 method: Self::MethodId,
141 params: Option<&'a RawValue>,
142 ) -> Result<Self::RequestParams<'a>, RouteError<'a>>;
143
144 /// Decodes a notification's params.
145 ///
146 /// # Errors
147 ///
148 /// [`RouteError::InvalidParams`] if `params` does not fit the method.
149 fn decode_notification_params<'a>(
150 &self,
151 method: Self::MethodId,
152 params: Option<&'a RawValue>,
153 ) -> Result<Self::NotificationParams<'a>, RouteError<'a>>;
154}
155
156/// A routed message: the method it names and its params, already typed.
157///
158/// `R` and `N` are the request and notification params, which are different
159/// types because a request and a notification of the same method legitimately
160/// have different params. The reply arm carries neither, which is why there is
161/// no parameter for it.
162#[derive(Debug, Clone)]
163pub enum Routed<'a, M, R, N> {
164 /// A request, which expects a reply carrying this id.
165 Request {
166 /// The method the sender named.
167 method: M,
168 /// The id to answer with.
169 id: WireId<'a>,
170 /// The decoded params.
171 params: R,
172 },
173 /// A notification, which expects nothing.
174 Notification {
175 /// The method the sender named.
176 method: M,
177 /// The decoded params.
178 params: N,
179 },
180 /// A reply, passed through without routing.
181 ///
182 /// Carried rather than dropped because a peer that sent us a result is a
183 /// peer whose response we were waiting for, and the session engine decides
184 /// what to match it against. Discarding it here would push that decision
185 /// down into the transport.
186 Reply {
187 /// The id the reply answers.
188 id: WireId<'a>,
189 /// The reply payload, undecoded.
190 payload: ReplyPayload<'a>,
191 },
192}
193
194impl<M, R, N> Routed<'_, M, R, N> {
195 /// The method, for the two shapes that have one.
196 #[must_use]
197 pub const fn method(&self) -> Option<M>
198 where
199 M: Copy,
200 {
201 match self {
202 Self::Request { method, .. } | Self::Notification { method, .. } => Some(*method),
203 Self::Reply { .. } => None,
204 }
205 }
206}
207
208/// The undecoded half of a reply.
209///
210/// No `PartialEq`, for the reason on [`WireError`](crate::domain::jsonrpc::WireError).
211#[derive(Debug, Clone)]
212pub enum ReplyPayload<'a> {
213 /// A `result` member.
214 Result(&'a RawValue),
215 /// An `error` member, as its code and message.
216 Error {
217 /// The numeric code the sender used.
218 code: i64,
219 /// The human-readable message.
220 message: &'a str,
221 },
222}
223
224/// Why a frame was refused.
225///
226/// # Why the method name is borrowed
227///
228/// The name is borrowed from the frame rather than interned, because a table
229/// of "known" names is a second list that can disagree with the method table,
230/// and a disagreement here is silent: a legitimate method name that the intern
231/// list has not heard of would be reported to the operator as `unknown`, which
232/// is exactly the case an operator most needs to see. The frame outlives the
233/// error — the error is produced while reading it — so the borrow is free.
234#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
235pub enum RouteError<'a> {
236 /// The frame was not decodable at all.
237 #[error(transparent)]
238 Decode(#[from] crate::domain::jsonrpc::DecodeError),
239 /// A batch arrived and this session does not accept one (`-32600`).
240 ///
241 /// `InvalidRequest` rather than a code of its own: the specification has no
242 /// batch-specific code, and inventing one would make a peer that switches
243 /// on codes meet something it has never heard of.
244 #[error("this session does not accept batch frames")]
245 BatchNotAllowed,
246 /// The method is not in this protocol's table (`-32601`).
247 #[error("unknown method `{0}`")]
248 MethodNotFound(Method<'a>),
249 /// The params did not fit the method (`-32602`).
250 #[error("invalid params for `{method}`: {reason}")]
251 InvalidParams {
252 /// The method whose params did not fit.
253 method: Method<'a>,
254 /// Why.
255 reason: &'static str,
256 },
257}
258
259impl RouteError<'_> {
260 /// The JSON-RPC code this failure is reported as.
261 ///
262 /// The batch case answers `InvalidRequest` and the unknown-method case
263 /// answers `MethodNotFound`; both are the codes the specification gives,
264 /// and reporting a batch refusal as `-32601` would tell the peer the wrong
265 /// thing about which half of the problem to fix.
266 #[must_use]
267 pub const fn code(&self) -> IrErrorCode {
268 match self {
269 Self::Decode(decode) => decode.code(),
270 Self::BatchNotAllowed | Self::InvalidParams { .. } => IrErrorCode::InvalidRequest,
271 Self::MethodNotFound(_) => IrErrorCode::MethodNotFound,
272 }
273 }
274}
275
276/// Routes one message, decoding its params into the table's own types.
277///
278/// # Errors
279///
280/// [`RouteError::MethodNotFound`] if the table has no such method, and
281/// [`RouteError::InvalidParams`] if the params do not fit. A reply never
282/// errors — it is passed through, because interpreting it is the session
283/// engine's job rather than this layer's.
284///
285/// # Examples
286///
287/// ```
288/// use protocol_droid::application::jsonrpc::{MethodTable, RouteError, Routed, route_one};
289/// use protocol_droid::domain::jsonrpc::{Frame, decode};
290/// use serde_json::value::RawValue;
291///
292/// # #[derive(Debug, Clone, Copy, PartialEq, Eq)]
293/// # enum Ping { Method }
294/// # struct Table;
295/// # impl MethodTable for Table {
296/// # type MethodId = Ping;
297/// # type RequestParams<'a> = ();
298/// # type NotificationParams<'a> = ();
299/// # fn lookup(&self, name: &str) -> Option<Ping> { (name == "ping").then_some(Ping::Method) }
300/// # fn decode_request_params<'a>(&self, _: Ping, _: Option<&'a RawValue>) -> Result<(), RouteError<'a>> { Ok(()) }
301/// # fn decode_notification_params<'a>(&self, _: Ping, _: Option<&'a RawValue>) -> Result<(), RouteError<'a>> { Ok(()) }
302/// # }
303/// let frame = decode(br#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#)
304/// .expect("a well-formed request decodes");
305/// let Frame::Single(envelope) = frame else {
306/// panic!("a lone object is not a batch");
307/// };
308/// let routed = route_one(envelope, &Table).expect("ping is in the table");
309/// assert!(matches!(routed, Routed::Request { .. }));
310/// ```
311///
312/// Takes the envelope by value: a routed request owns the id it must answer
313/// with, and an id whose text needed unescaping owns a `String`. Borrowing
314/// would force a clone on exactly the ids where cloning costs, so the envelope
315/// is moved instead — the caller that is iterating a frame is iterating owned
316/// envelopes anyway.
317pub fn route_one<'a, M: MethodTable>(
318 envelope: Envelope<'a>,
319 table: &M,
320) -> Result<RoutedMessage<'a, M>, RouteError<'a>> {
321 let Envelope {
322 kind,
323 method,
324 params,
325 id,
326 outcome,
327 } = envelope;
328 let method = method.unwrap_or_default();
329 match kind {
330 MessageKind::Request => {
331 let Some(id) = id else {
332 return Err(RouteError::InvalidParams {
333 method,
334 reason: "a request must carry an id",
335 });
336 };
337 let Some(found) = table.lookup(method.as_ref()) else {
338 return Err(RouteError::MethodNotFound(method));
339 };
340 let params = table.decode_request_params(found, params)?;
341 Ok(Routed::Request {
342 method: found,
343 id,
344 params,
345 })
346 }
347 MessageKind::Notification => {
348 let Some(found) = table.lookup(method.as_ref()) else {
349 return Err(RouteError::MethodNotFound(method));
350 };
351 let params = table.decode_notification_params(found, params)?;
352 Ok(Routed::Notification {
353 method: found,
354 params,
355 })
356 }
357 MessageKind::Result | MessageKind::Error => {
358 let Some(id) = id else {
359 return Err(RouteError::InvalidParams {
360 method: Method::Borrowed("a reply"),
361 reason: "must carry an id",
362 });
363 };
364 let payload = match outcome {
365 Some(Outcome::Result(value)) => ReplyPayload::Result(value),
366 Some(Outcome::Error(error)) => ReplyPayload::Error {
367 code: error.code,
368 message: error.message,
369 },
370 // `decode` refuses a message with neither, so this arm is
371 // unreachable rather than reachable. The alternative is an
372 // error a peer can trigger, which is worse than either.
373 None => {
374 return Err(RouteError::InvalidParams {
375 method: Method::Borrowed("a reply"),
376 reason: "must carry a result or an error",
377 });
378 }
379 };
380 Ok(Routed::Reply { id, payload })
381 }
382 }
383}
384
385/// Decodes a frame and routes every message in it, refusing a batch when
386/// `policy` says this session does not accept one.
387///
388/// # Errors
389///
390/// [`RouteError::BatchNotAllowed`] for a batch the policy refuses, and
391/// whatever [`route_one`] returns for the first message that cannot be routed.
392///
393/// A batch is all-or-nothing on the way in. The specification lets a server
394/// answer each element independently, but it cannot answer any of them before
395/// the whole frame is decoded, so a bad element is found before a single
396/// handler runs. That is also the only reading that keeps a refusal from
397/// having run half the batch.
398pub fn route_frame<'a, M: MethodTable, P: BatchPolicy>(
399 input: &'a [u8],
400 table: &M,
401 profile: &FeatureProfile,
402 policy: &P,
403) -> Result<RoutedFrame<'a, M>, RouteError<'a>> {
404 let frame = decode(input)?;
405 if frame.is_batch() && !policy.allows_batch(profile) {
406 return Err(RouteError::BatchNotAllowed);
407 }
408 frame
409 .into_envelopes()
410 .into_iter()
411 .map(|envelope| route_one(envelope, table))
412 .collect()
413}
414
415#[cfg(test)]
416mod tests {
417 // Test code unwraps deliberately: the strict profile exists to keep
418 // production paths fallible, and a test that cannot unwrap cannot assert.
419 // The messages name the failing precondition, which is the useful part.
420 #![expect(
421 clippy::expect_used,
422 clippy::panic,
423 reason = "tests assert on fallible calls and panic with the failing precondition; propagating errors would replace a clear failure message with noise"
424 )]
425 //! The router, with a stand-in table.
426 //!
427 //! # Why a stand-in rather than MCP's real table
428 //!
429 //! `application` may not name an adapter — that is the hexagonal rule, and
430 //! a test that breaks it teaches the wrong lesson. So the table here is
431 //! defined in this module with two methods and no protocol meaning. The
432 //! real MCP table is tested against this same router in
433 //! `adapters/secondary/mcp/method_table.rs`, which is the one place the
434 //! dependency is allowed to point.
435 //!
436 //! These tests live in the module because a dependent crate in `tests/`
437 //! cannot keep the compiler from calling a public-but-unused item dead
438 //! when it builds this crate as a test binary. `tests/jsonrpc_router.rs`
439 //! still exists as the cross-layer check; this is the unit check.
440
441 use assert_matches::assert_matches;
442 use serde_json::value::RawValue;
443
444 use crate::domain::feature::FeatureProfile;
445 use crate::domain::ir::IrErrorCode;
446 use crate::domain::jsonrpc::WireId;
447
448 use super::{MethodTable, ReplyPayload, RouteError, Routed, route_frame, route_one};
449 use crate::application::jsonrpc::{AllowBatching, DenyBatching, ProfileBatching};
450
451 type TestError = Box<dyn std::error::Error>;
452 type Params<'a> = Option<&'a RawValue>;
453
454 /// Two methods with no protocol meaning, so a test cannot pass by
455 /// accident on a name the specification happens to define.
456 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
457 enum TestMethod {
458 Alpha,
459 Beta,
460 }
461
462 /// A table that only knows two methods, and that records whether the
463 /// params decoder ran — the point of the whole "decode after routing"
464 /// arrangement is that an unroutable frame never pays for it.
465 struct TestTable {
466 decoded: std::cell::Cell<usize>,
467 }
468
469 impl TestTable {
470 const fn new() -> Self {
471 Self {
472 decoded: std::cell::Cell::new(0),
473 }
474 }
475 }
476
477 impl MethodTable for TestTable {
478 type MethodId = TestMethod;
479 type RequestParams<'a> = Params<'a>;
480 type NotificationParams<'a> = Params<'a>;
481
482 fn lookup(&self, name: &str) -> Option<TestMethod> {
483 match name {
484 "alpha" => Some(TestMethod::Alpha),
485 "beta" => Some(TestMethod::Beta),
486 _ => None,
487 }
488 }
489
490 fn decode_request_params<'a>(
491 &self,
492 _method: TestMethod,
493 params: Option<&'a RawValue>,
494 ) -> Result<Self::RequestParams<'a>, RouteError<'a>> {
495 self.decoded.set(self.decoded.get() + 1);
496 Ok(params)
497 }
498
499 fn decode_notification_params<'a>(
500 &self,
501 _method: TestMethod,
502 params: Option<&'a RawValue>,
503 ) -> Result<Self::NotificationParams<'a>, RouteError<'a>> {
504 self.decoded.set(self.decoded.get() + 1);
505 Ok(params)
506 }
507 }
508
509 /// A batch, as a peer would send it.
510 const BATCH: &[u8] =
511 br#"[{"jsonrpc":"2.0","id":1,"method":"alpha"},{"jsonrpc":"2.0","id":2,"method":"beta"}]"#;
512
513 #[test]
514 fn a_request_reports_its_method_through_the_accessor() {
515 let table = TestTable::new();
516 let input = br#"{"jsonrpc":"2.0","id":1,"method":"alpha"}"#;
517 let routed = route_one(single(input), &table).expect("alpha is in the table");
518 assert_eq!(routed.method(), Some(TestMethod::Alpha));
519 }
520
521 #[test]
522 fn a_reply_reports_no_method() {
523 let table = TestTable::new();
524 let input = br#"{"jsonrpc":"2.0","id":1,"result":null}"#;
525 let routed = route_one(single(input), &table).expect("a reply routes to no method");
526 assert_eq!(routed.method(), None, "a reply has no method to name");
527 }
528
529 #[test]
530 fn an_unroutable_frame_never_decodes_its_params() {
531 // The whole reason params decoding is lazy: a frame whose method does
532 // not exist is refused on the name alone, and the params are never
533 // looked at. If this fails, a peer can make the server parse arbitrary
534 // JSON by naming a method that does not exist.
535 let table = TestTable::new();
536 let input = br#"{"jsonrpc":"2.0","id":1,"method":"nope","params":{"deep":[1,2,3]}}"#;
537 let error = route_one(single(input), &table).expect_err("no such method");
538 assert_eq!(error.code(), IrErrorCode::MethodNotFound);
539 assert_eq!(table.decoded.get(), 0, "params decoding must not have run");
540 }
541
542 #[test]
543 fn a_routable_frame_decodes_its_params_exactly_once() {
544 let table = TestTable::new();
545 let input = br#"{"jsonrpc":"2.0","id":1,"method":"alpha","params":{"x":1}}"#;
546 route_one(single(input), &table).expect("alpha is in the table");
547 assert_eq!(table.decoded.get(), 1);
548 }
549
550 #[test]
551 fn a_batch_routes_every_element_in_order() {
552 let table = TestTable::new();
553 let routed = route_frame(BATCH, &table, &FeatureProfile::all(), &ProfileBatching)
554 .expect("batching is allowed in this profile");
555 let methods: Vec<_> = routed.iter().filter_map(Routed::method).collect();
556 assert_eq!(methods, [TestMethod::Alpha, TestMethod::Beta]);
557 }
558
559 #[test]
560 fn a_decode_failure_outranks_the_batch_policy() {
561 // A malformed frame is malformed whether or not batching is on. If
562 // the policy were checked first, a session with batching enabled would
563 // report `-32700` as `-32600` and a peer could not tell a broken
564 // frame from a refused one.
565 let table = TestTable::new();
566 let error = route_frame(
567 b"{not json",
568 &table,
569 &FeatureProfile::all(),
570 &ProfileBatching,
571 )
572 .expect_err("the frame is malformed");
573 assert_eq!(error.code(), IrErrorCode::ParseError);
574 }
575
576 #[test]
577 fn a_single_message_is_unaffected_by_the_batch_policy() {
578 // Refusing batches must not refuse messages, or a server that has
579 // turned batching off answers nothing at all.
580 let input = br#"{"jsonrpc":"2.0","id":1,"method":"alpha"}"#;
581 // Two calls rather than a loop: the two policies are different types,
582 // and a `[DenyBatching, AllowBatching]` array would have to erase them
583 // to a trait object to iterate — which is the allocation this whole
584 // layer exists to avoid.
585 let table = TestTable::new();
586 let routed = route_frame(input, &table, &FeatureProfile::empty(), &DenyBatching)
587 .expect("a single message is not a batch");
588 assert_eq!(
589 routed.len(),
590 1,
591 "DenyBatching must not refuse a single message"
592 );
593
594 let table = TestTable::new();
595 let routed = route_frame(input, &table, &FeatureProfile::empty(), &AllowBatching)
596 .expect("a single message is not a batch");
597 assert_eq!(
598 routed.len(),
599 1,
600 "AllowBatching must not refuse a single message"
601 );
602 }
603
604 #[test]
605 fn the_profile_decides_whether_a_batch_is_allowed() {
606 let table = TestTable::new();
607 let error = route_frame(BATCH, &table, &FeatureProfile::empty(), &ProfileBatching)
608 .expect_err("this profile has no batching");
609 assert_eq!(
610 error.code(),
611 IrErrorCode::InvalidRequest,
612 "an unacceptable batch is `-32600`, not a code of its own"
613 );
614 }
615
616 #[test]
617 fn a_policy_that_ignores_the_profile_overrides_it_both_ways() {
618 // `AllowBatching` and `DenyBatching` exist for the conformance runner,
619 // which has to exercise both behaviours regardless of what a profile
620 // says. If they were not genuinely unconditional, the runner would be
621 // testing the profile and calling it a policy.
622 let table = TestTable::new();
623 assert!(
624 route_frame(BATCH, &table, &FeatureProfile::empty(), &AllowBatching).is_ok(),
625 "AllowBatching must allow a batch the profile refuses"
626 );
627 let table = TestTable::new();
628 assert!(
629 route_frame(BATCH, &table, &FeatureProfile::all(), &DenyBatching).is_err(),
630 "DenyBatching must refuse a batch the profile allows"
631 );
632 }
633
634 #[test]
635 fn a_null_id_is_a_malformed_frame_not_a_notification() {
636 // The case the decoder's `present_member` exists for. JSON-RPC treats
637 // an absent `id` as "this is a notification"; a peer that sends
638 // `"id": null` is not saying that, it is sending an id that is not a
639 // number or a string. Collapsing the two would make the request
640 // unanswerable with no error at all, so the decoder keeps them
641 // distinct and refuses this one as `-32600`.
642 let input = br#"{"jsonrpc":"2.0","id":null,"method":"alpha"}"#;
643 let error = crate::domain::jsonrpc::decode(input).expect_err("a null id is not a valid id");
644 assert_eq!(error.code(), IrErrorCode::InvalidRequest);
645 assert!(
646 error.to_string().contains("number or a string"),
647 "and says why: {error}"
648 );
649 }
650
651 #[test]
652 fn a_message_carrying_neither_method_nor_reply_is_refused_before_routing() {
653 // Refused by the decoder, not the router. That ordering matters: an
654 // `id` with no `method` and no `result` is not a request for an
655 // unknown method, so `-32601` would be a lie, and it never reaches a
656 // table at all.
657 let input = br#"{"jsonrpc":"2.0","id":1}"#;
658 let error = crate::domain::jsonrpc::decode(input)
659 .expect_err("a message must be a request, a notification, or a reply");
660 assert_eq!(error.code(), IrErrorCode::InvalidRequest);
661 assert!(
662 error.to_string().contains("method or a reply"),
663 "and says which half is missing: {error}"
664 );
665 }
666
667 #[test]
668 fn a_null_result_is_a_result() -> Result<(), TestError> {
669 // The mirror of the `id` case, and the reason `result` also uses
670 // `present_member`. `"result": null` is a real result, not an absent
671 // one, and MCP sends it — a `tools/call` with empty content, a handler
672 // that returns nothing. Read as absent it looks like a reply with no
673 // payload, and the peer is told `-32600` for sending a valid message.
674 let table = TestTable::new();
675 let input = br#"{"jsonrpc":"2.0","id":1,"result":null}"#;
676 match route_one(single(input), &table) {
677 Ok(Routed::Reply { id, payload }) => {
678 assert_eq!(id, WireId::Number(1));
679 assert_matches!(payload, ReplyPayload::Result(raw) if raw.get() == "null");
680 }
681 other => return Err(format!("expected a reply, got {other:?}").into()),
682 }
683 Ok(())
684 }
685
686 #[test]
687 fn an_error_reply_keeps_its_code_and_message() -> Result<(), TestError> {
688 let table = TestTable::new();
689 let input = br#"{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"nope"}}"#;
690 match route_one(single(input), &table) {
691 Ok(Routed::Reply { id, payload }) => {
692 assert_eq!(id, WireId::Number(1));
693 assert_matches!(
694 payload,
695 ReplyPayload::Error { code, message }
696 if code == -32601 && message == "nope"
697 );
698 }
699 other => return Err(format!("expected a reply, got {other:?}").into()),
700 }
701 Ok(())
702 }
703
704 /// The router takes an owned envelope, so the tests need the single-message
705 /// unwrap that `route_frame` does for them.
706 fn single(input: &[u8]) -> crate::domain::jsonrpc::Envelope<'_> {
707 match crate::domain::jsonrpc::decode(input) {
708 Ok(crate::domain::jsonrpc::Frame::Single(envelope)) => envelope,
709 // Both arms are a test-authoring mistake, not a protocol outcome:
710 // every caller above passes one well-formed object. A panic here
711 // is the honest signal for that, and it is in test code.
712 Ok(crate::domain::jsonrpc::Frame::Batch(_)) => {
713 panic!("these tests each pass exactly one message")
714 }
715 Err(error) => panic!("these tests each pass well-formed JSON: {error}"),
716 }
717 }
718}