Skip to content

Commit cfba148

Browse files
committed
test: add SEP-2322 MRTR conformance scenarios
1 parent a8c71e6 commit cfba148

4 files changed

Lines changed: 530 additions & 1 deletion

File tree

.github/workflows/conformance.yml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,38 @@ jobs:
8888
-o conformance-results
8989
done
9090
91+
# SEP-2322 MRTR scenarios (spec 2026-07-28). They speak the stateless
92+
# lifecycle (bare JSON-RPC POSTs, no initialize handshake), so they run
93+
# against the stateless draft server.
94+
- name: Run SEP-2322 MRTR scenarios
95+
run: |
96+
for scenario in \
97+
input-required-result-basic-elicitation \
98+
input-required-result-basic-sampling \
99+
input-required-result-basic-list-roots \
100+
input-required-result-request-state \
101+
input-required-result-multiple-input-requests \
102+
input-required-result-multi-round \
103+
input-required-result-missing-input-response \
104+
input-required-result-non-tool-request \
105+
input-required-result-result-type \
106+
input-required-result-unsupported-methods \
107+
input-required-result-tampered-state \
108+
input-required-result-capability-check \
109+
input-required-result-ignore-extra-params \
110+
input-required-result-validate-input \
111+
; do
112+
npx -y "@modelcontextprotocol/conformance@${DRAFT_CONFORMANCE_VERSION}" server \
113+
--url http://127.0.0.1:8002/mcp \
114+
--scenario "$scenario" \
115+
-o conformance-results
116+
done
117+
91118
- name: Stop draft conformance server
92119
if: always()
93120
run: kill "$(cat draft-server.pid)" 2>/dev/null || true
94121

122+
95123
- name: Stop conformance server
96124
if: always()
97125
run: kill "$(cat server.pid)" 2>/dev/null || true
@@ -126,6 +154,14 @@ jobs:
126154
--spec-version 2025-11-25 \
127155
-o conformance-client-results/full
128156
157+
# SEP-2322 MRTR client scenario (spec 2026-07-28).
158+
- name: Run SEP-2322 MRTR client scenario
159+
run: |
160+
npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_DRAFT_VERSION}" client \
161+
--command "$(pwd)/target/debug/conformance-client" \
162+
--scenario sep-2322-client-request-state \
163+
-o conformance-client-results/mrtr
164+
129165
- name: Upload results
130166
if: always()
131167
uses: actions/upload-artifact@v7

conformance/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ rmcp = { path = "../crates/rmcp", features = [
1818
"client",
1919
"elicitation",
2020
"auth",
21+
"request-state",
2122
"transport-streamable-http-server",
2223
"transport-streamable-http-client-reqwest",
2324
] }

conformance/src/bin/client.rs

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use rmcp::{
22
ClientHandler, ErrorData, RoleClient, ServiceExt,
33
model::*,
4-
service::RequestContext,
4+
service::{RequestContext, serve_directly},
55
transport::{
66
AuthClient, AuthorizationManager, StreamableHttpClientTransport,
77
auth::{AuthorizationCallback, OAuthState},
@@ -824,6 +824,95 @@ async fn run_elicitation_defaults_client(server_url: &str) -> anyhow::Result<()>
824824
Ok(())
825825
}
826826

827+
/// A minimal stateless client transport: every outgoing message is one HTTP
828+
/// POST and the JSON response body (if any) is queued for `receive()`.
829+
///
830+
/// The SEP-2322 client scenario's mock server speaks the stateless lifecycle
831+
/// (no `initialize` handshake, plain JSON responses), which the session-based
832+
/// `StreamableHttpClientTransport` cannot do. The transport is harness
833+
/// plumbing; the behavior under test — the SDK's MRTR retry driver — runs
834+
/// unchanged on top of it.
835+
struct StatelessHttpTransport {
836+
http: reqwest::Client,
837+
uri: std::sync::Arc<str>,
838+
tx: tokio::sync::mpsc::Sender<ServerJsonRpcMessage>,
839+
rx: tokio::sync::mpsc::Receiver<ServerJsonRpcMessage>,
840+
}
841+
842+
impl StatelessHttpTransport {
843+
fn new(uri: &str) -> Self {
844+
let (tx, rx) = tokio::sync::mpsc::channel(16);
845+
Self {
846+
http: reqwest::Client::new(),
847+
uri: uri.into(),
848+
tx,
849+
rx,
850+
}
851+
}
852+
}
853+
854+
impl rmcp::transport::Transport<RoleClient> for StatelessHttpTransport {
855+
type Error = std::io::Error;
856+
857+
fn send(
858+
&mut self,
859+
item: rmcp::model::ClientJsonRpcMessage,
860+
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send + 'static {
861+
let http = self.http.clone();
862+
let uri = self.uri.clone();
863+
let tx = self.tx.clone();
864+
async move {
865+
let response = http
866+
.post(uri.as_ref())
867+
.header("MCP-Protocol-Version", "2026-07-28")
868+
.json(&item)
869+
.send()
870+
.await
871+
.map_err(std::io::Error::other)?;
872+
match response.json::<ServerJsonRpcMessage>().await {
873+
Ok(message) => {
874+
let _ = tx.send(message).await;
875+
}
876+
Err(_) => {
877+
// No JSON-RPC body (e.g. 202/204 for notifications).
878+
}
879+
}
880+
Ok(())
881+
}
882+
}
883+
884+
async fn receive(&mut self) -> Option<ServerJsonRpcMessage> {
885+
self.rx.recv().await
886+
}
887+
888+
async fn close(&mut self) -> Result<(), Self::Error> {
889+
Ok(())
890+
}
891+
}
892+
893+
/// SEP-2322 MRTR client scenario: the mock server has no `initialize` handler
894+
/// (stateless lifecycle), so skip the handshake with `serve_directly` and let
895+
/// the high-level `call_tool` helper drive the `input_required` retry rounds.
896+
/// The mock server verifies requestState echo, fresh JSON-RPC ids on retry,
897+
/// state omission, isolation between tools, and the `resultType` default.
898+
async fn run_mrtr_client(server_url: &str) -> anyhow::Result<()> {
899+
let transport = StatelessHttpTransport::new(server_url);
900+
let peer_info = InitializeResult::new(ServerCapabilities::builder().enable_tools().build())
901+
.with_protocol_version(ProtocolVersion::V_2026_07_28);
902+
let client = serve_directly(FullClientHandler, transport, Some(peer_info));
903+
904+
let tools = client.list_tools(Default::default()).await?;
905+
tracing::debug!("Listed {} tools", tools.tools.len());
906+
for tool in &tools.tools {
907+
let result = client
908+
.call_tool(CallToolRequestParams::new(tool.name.clone()))
909+
.await;
910+
tracing::debug!("Called {}: {:?}", tool.name, result.is_ok());
911+
}
912+
client.cancel().await?;
913+
Ok(())
914+
}
915+
827916
async fn run_sse_retry_client(server_url: &str) -> anyhow::Result<()> {
828917
let transport = StreamableHttpClientTransport::from_uri(server_url);
829918
let client = BasicClientHandler.serve(transport).await?;
@@ -869,6 +958,7 @@ async fn main() -> anyhow::Result<()> {
869958
run_elicitation_defaults_client(&server_url).await?
870959
}
871960
"sse-retry" => run_sse_retry_client(&server_url).await?,
961+
"sep-2322-client-request-state" => run_mrtr_client(&server_url).await?,
872962

873963
// Auth scenarios - standard OAuth flow
874964
"auth/metadata-default"

0 commit comments

Comments
 (0)