protocol-droid 0.5.0

A Model Context Protocol toolkit that speaks every published spec revision, with one canonical IR behind many per-revision codecs.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
//! Carries envelopes over a `2024-11-05` stream.
//!
//! ## Why this is a wrapper and not an `impl` on [`LegacySseClient`]
//!
//! The [`HttpClient`](crate::adapters::secondary::transport::http::HttpClient)
//! peer is a plain `impl BridgePeer` on the client type, because an HTTP reply
//! arrives in the response to the POST that asked for it. There is nothing to
//! correlate and nowhere for an unsolicited frame to appear.
//!
//! This transport has both problems. `send` answers `202` with an empty body
//! and the real answer arrives later as a `message` event on the stream, so
//! `send` has to read until it finds the frame carrying *its own* id — and a
//! frame that belongs to no call in flight, such as a server-pushed
//! notification, arrives on the same stream and belongs to nobody. Neither
//! needs new state on the client to handle, so the wrapper owns it.
//!
//! ## The id is the caller's, not ours
//!
//! Same rule as the HTTP peer, and for the same reason: a proxy that renumbers
//! the id leaves the downstream client waiting for a reply carrying an id it
//! never sent.
//!
//! ## An unsolicited frame is buffered, never dropped
//!
//! The crate's whole promise is that an unmodelled directive reaches the other
//! side byte-for-byte. A notification the server pushed while the bridge waited
//! for a call's answer is real traffic, and discarding it to make the
//! correlation loop tidy would quietly violate that. It goes to
//! [`take_pending`](Self::take_pending) instead, and the caller decides where it
//! goes next.

use std::sync::Arc;

use serde_json::{Map, Value};

use crate::application::bridge::{BridgeError, BridgePeer, SendFuture, Unsolicited};

use super::{ClientFrame, LegacySseClient, LegacySseError};

/// A `2024-11-05` peer that holds the frames nothing was waiting for.
#[derive(Debug)]
pub struct LegacySsePeer {
    inner: Arc<LegacySseClient>,
    unsolicited: Unsolicited,
}

impl LegacySsePeer {
    /// Wraps a connected client.
    #[must_use]
    pub fn new(inner: Arc<LegacySseClient>) -> Self {
        Self {
            inner,
            unsolicited: Unsolicited::new(),
        }
    }

    /// The client underneath, for the parts of it a bridge does not use.
    #[must_use]
    pub const fn client(&self) -> &Arc<LegacySseClient> {
        &self.inner
    }

    /// Takes every frame that arrived while some other call was in flight.
    pub fn take_pending(&self) -> Vec<Map<String, Value>> {
        self.unsolicited.take()
    }
}

impl BridgePeer for LegacySsePeer {
    /// Delivers one envelope and yields the frame carrying its answer.
    ///
    /// # Errors
    ///
    /// [`BridgeError::Unreachable`] when the POST failed, when the stream
    /// closed before the answer arrived, or when the answer was not an object.
    /// A peer that answered with an `error` member is *not* an error here: it
    /// comes back through `Ok`, because a declined request is a successful
    /// exchange.
    fn send(
        &self,
        envelope: Map<String, Value>,
    ) -> SendFuture<'_, Result<Map<String, Value>, BridgeError>> {
        Box::pin(async move {
            // A notification has no id, so there is nothing to match the answer
            // against and a `send` that tried would read the stream until it
            // hung. `notify` is the method for that, and reaching here with no
            // id is the caller using the wrong one.
            let Some(id) = envelope.get("id").cloned() else {
                return Err(BridgeError::Unreachable {
                    reason: "a notification was sent through the request path".to_owned(),
                });
            };
            self.inner
                .send(&encode(&envelope)?)
                .await
                .map_err(|error| unreachable(&error))?;

            loop {
                match self
                    .inner
                    .next()
                    .await
                    .map_err(|error| unreachable(&error))?
                {
                    None => {
                        return Err(BridgeError::Unreachable {
                            reason: "the stream closed before the peer answered".to_owned(),
                        });
                    }
                    // The `endpoint` event is how the client learned where to
                    // post in the first place; a second one carries nothing
                    // this call is waiting for.
                    Some(ClientFrame::Endpoint(_)) => {}
                    Some(ClientFrame::Message(value)) => {
                        let Some(answer) = value.as_object().cloned() else {
                            return Err(BridgeError::Unreachable {
                                reason: "the peer answered with something that is not a JSON-RPC message"
                                    .to_owned(),
                            });
                        };
                        if answer.get("id") == Some(&id) {
                            return Ok(answer);
                        }
                        // Someone else's frame, or an unsolicited notification.
                        // Held rather than dropped — see the module docs.
                        self.unsolicited.hold(answer);
                    }
                }
            }
        })
    }

    /// Delivers one envelope that expects no answer.
    ///
    /// # Errors
    ///
    /// [`BridgeError::Unreachable`] when the POST failed.
    fn notify(&self, envelope: Map<String, Value>) -> SendFuture<'_, Result<(), BridgeError>> {
        Box::pin(async move {
            self.inner
                .send(&encode(&envelope)?)
                .await
                .map_err(|error| unreachable(&error))
        })
    }

    /// Ends the upstream session.
    ///
    /// # Errors
    ///
    /// Never. `2024-11-05` has no teardown verb — the stream closing *is* the
    /// teardown, and dropping the client drops it. Reporting a failure here
    /// would be reporting on something the transport does not have.
    fn close(&self) -> SendFuture<'_, Result<(), BridgeError>> {
        Box::pin(async { Ok(()) })
    }

    /// The frames the stream carried while another call was in flight.
    ///
    /// Overrides the trait's empty answer because `send` walks past every frame
    /// that is not its own answer and holds it here rather than discarding it —
    /// see [`send`](Self::send).
    fn take_pending(&self) -> Vec<Map<String, Value>> {
        Self::take_pending(self)
    }

    fn wait_pending(&self) -> SendFuture<'static, ()> {
        // The queue is where the frames are, so waiting on the queue is
        // waiting on the peer.
        Box::pin(self.unsolicited.wait())
    }
}

/// Serialises an envelope for the POST body.
///
/// Mapping by hand rather than with `?`: there is no `From<serde_json::Error>`
/// into [`BridgeError`], because a failure to *encode* our own outbound
/// envelope says nothing about the peer — it is a bridge-side fault, and the
/// nearest honest thing to say to the downstream client is that the exchange
/// could not be completed.
fn encode(envelope: &Map<String, Value>) -> Result<Vec<u8>, BridgeError> {
    serde_json::to_vec(&Value::Object(envelope.clone())).map_err(|error| BridgeError::Unreachable {
        reason: format!("the envelope could not be encoded for the wire: {error}"),
    })
}

/// Maps a transport failure onto the bridge's vocabulary.
fn unreachable(error: &LegacySseError) -> BridgeError {
    BridgeError::Unreachable {
        reason: error.to_string(),
    }
}

#[cfg(test)]
mod tests {
    use std::sync::Arc;
    use std::sync::atomic::{AtomicU64, Ordering};

    use serde_json::{Map, Value, json};

    use crate::adapters::primary::transport::http::{Refusal, ServerConfig, SessionStore};
    use crate::adapters::primary::transport::legacy_sse::{
        DEFAULT_STREAM_PATH, LegacyDispatcher, LegacyRouter, LegacyTail,
    };
    use crate::application::bridge::{BridgeError, BridgePeer};
    use crate::application::session::IdSource;
    use crate::domain::McpSessionId;
    use crate::domain::registry::AuthContext;
    use crate::domain::session::SessionId;

    use super::LegacySsePeer;
    use crate::adapters::secondary::transport::legacy_sse::{LegacySseClient, LegacySseConfig};

    type TestError = Box<dyn std::error::Error>;

    struct Counting(AtomicU64);

    impl IdSource for Counting {
        fn next_session_id(&self) -> SessionId {
            SessionId::new(self.0.fetch_add(1, Ordering::SeqCst))
        }
    }

    /// The real `2024-11-05` router, so these tests are a claim about the two
    /// halves of this crate agreeing rather than about a stub agreeing with a
    /// stub. A peer that pushes an extra frame needs a dispatcher that does
    /// that, and the real router has no reason to.
    struct Pushes {
        unsolicited: Option<Value>,
        decline: bool,
    }

    impl LegacyDispatcher for Pushes {
        type Session = ();

        fn open(&self, _id: SessionId, _tail: &LegacyTail) {}

        fn dispatch(
            &self,
            _auth: &AuthContext,
            _session: &McpSessionId,
            _state: &(),
            frame: &Value,
        ) -> impl core::future::Future<Output = Result<Vec<Value>, Refusal>> + Send {
            core::future::ready({
                let id = frame.get("id").cloned().unwrap_or(Value::Null);
                let answer = if self.decline {
                    json!({"jsonrpc": "2.0", "id": id, "error": {"code": -32601, "message": "no"}})
                } else {
                    json!({"jsonrpc": "2.0", "id": id, "result": {"ok": true}})
                };
                match self.unsolicited.clone() {
                    Some(extra) => Ok(vec![extra, answer]),
                    None => Ok(vec![answer]),
                }
            })
        }
    }

    async fn peer(dispatcher: Pushes) -> Result<LegacySsePeer, TestError> {
        let router = LegacyRouter::new(
            ServerConfig::new()?,
            Arc::new(SessionStore::new(Arc::new(Counting(AtomicU64::new(0))))),
            dispatcher,
        )
        .into_router()?;

        // Port 0 so two tests can run at once; a fixed port fails the moment
        // anything else takes it, and looks like a transport bug.
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
        let port = listener.local_addr()?.port();
        tokio::spawn(async move {
            let _ = axum::serve(listener, router).await;
        });

        let config =
            LegacySseConfig::new(&format!("http://127.0.0.1:{port}{DEFAULT_STREAM_PATH}"))?;
        let client = LegacySseClient::connect(config).await?;
        Ok(LegacySsePeer::new(Arc::new(client)))
    }

    fn envelope(text: &str) -> Result<Map<String, Value>, TestError> {
        Ok(serde_json::from_str(text)?)
    }

    /// The point of the transport: the answer comes back on the stream, carrying
    /// the id the caller sent. Renumbering it would leave the downstream client
    /// waiting for a reply it was never going to receive.
    #[tokio::test]
    async fn an_answer_comes_back_on_the_stream_carrying_the_callers_id() -> Result<(), TestError> {
        let peer = peer(Pushes {
            unsolicited: None,
            decline: false,
        })
        .await?;

        let answer = peer
            .send(envelope(r#"{"jsonrpc":"2.0","id":41,"method":"ping"}"#)?)
            .await?;

        assert_eq!(answer.get("id"), Some(&json!(41)));
        assert_eq!(answer.get("result"), Some(&json!({"ok": true})));
        Ok(())
    }

    /// A frame that belongs to no call in flight is held, not dropped.
    ///
    /// The crate promises lossless forwarding, and a server-pushed
    /// notification is real traffic. Discarding it to keep the correlation loop
    /// tidy would violate that silently.
    #[tokio::test]
    async fn an_unsolicited_frame_is_held_rather_than_dropped() -> Result<(), TestError> {
        let pushed = json!({
            "jsonrpc": "2.0",
            "method": "notifications/tools/list_changed"
        });
        let peer = peer(Pushes {
            unsolicited: Some(pushed.clone()),
            decline: false,
        })
        .await?;

        let answer = peer
            .send(envelope(r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#)?)
            .await?;

        assert_eq!(answer.get("id"), Some(&json!(1)));
        let held = peer.take_pending();
        assert_eq!(held.len(), 1, "the pushed frame was not held");
        assert_eq!(
            held.first().and_then(|frame| frame.get("method")),
            Some(&json!("notifications/tools/list_changed"))
        );

        // Draining, not cloning: the second poll must not hand it back again.
        assert!(peer.take_pending().is_empty());
        Ok(())
    }

    /// A declined request is a successful exchange, not a transport fault.
    ///
    /// Conflating the two tells the downstream client "network error" about a
    /// request the server processed and deliberately refused.
    #[tokio::test]
    async fn a_refusal_comes_back_through_ok_carrying_the_error_member() -> Result<(), TestError> {
        let peer = peer(Pushes {
            unsolicited: None,
            decline: true,
        })
        .await?;

        let answer = peer
            .send(envelope(
                r#"{"jsonrpc":"2.0","id":9,"method":"tools/call"}"#,
            )?)
            .await?;

        assert_eq!(
            answer
                .get("error")
                .and_then(|error| error.get("code"))
                .and_then(Value::as_i64),
            Some(-32601)
        );
        Ok(())
    }

    /// A notification sent down the request path is refused, not waited on.
    ///
    /// It has no id, so the correlation loop would match nothing and read the
    /// stream until it hung. `notify` is the method for it.
    #[tokio::test]
    async fn a_notification_on_the_request_path_is_refused() -> Result<(), TestError> {
        let peer = peer(Pushes {
            unsolicited: None,
            decline: false,
        })
        .await?;

        let outcome = peer
            .send(envelope(
                r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
            )?)
            .await;

        assert!(
            matches!(outcome, Err(BridgeError::Unreachable { .. })),
            "a notification on the request path was not refused"
        );
        Ok(())
    }

    /// A notification really is deliverable: it posts and expects no answer.
    #[tokio::test]
    async fn a_notification_posts_without_waiting_for_a_reply() -> Result<(), TestError> {
        let peer = peer(Pushes {
            unsolicited: None,
            decline: false,
        })
        .await?;

        peer.notify(envelope(
            r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
        )?)
        .await?;
        Ok(())
    }
}