Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .github/workflows/conformance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ concurrency:
env:
# Pinned for reproducible runs; bump deliberately when the suite updates.
CONFORMANCE_VERSION: "0.1.16"
DRAFT_CONFORMANCE_VERSION: "0.2.0-alpha.9"

jobs:
server:
Expand Down Expand Up @@ -64,6 +65,61 @@ jobs:
-o conformance-results
done

- name: Start draft conformance server
run: |
STATELESS=1 PORT=8002 ./target/debug/conformance-server &
echo $! > draft-server.pid
for _ in $(seq 1 30); do
if curl -s -o /dev/null http://127.0.0.1:8002/mcp; then
exit 0
fi
sleep 1
done
echo "draft conformance server did not become ready" >&2
exit 1

- name: Run draft SEP scenarios
run: |
for scenario in sep-2164-resource-not-found caching http-header-validation; do
npx -y "@modelcontextprotocol/conformance@${DRAFT_CONFORMANCE_VERSION}" server \
--url http://127.0.0.1:8002/mcp \
--scenario "$scenario" \
--spec-version draft \
-o conformance-results
done

# SEP-2322 MRTR scenarios (spec 2026-07-28). They speak the stateless
# lifecycle (bare JSON-RPC POSTs, no initialize handshake), so they run
# against the stateless draft server.
- name: Run SEP-2322 MRTR scenarios
run: |
for scenario in \
input-required-result-basic-elicitation \
input-required-result-basic-sampling \
input-required-result-basic-list-roots \
input-required-result-request-state \
input-required-result-multiple-input-requests \
input-required-result-multi-round \
input-required-result-missing-input-response \
input-required-result-non-tool-request \
input-required-result-result-type \
input-required-result-unsupported-methods \
input-required-result-tampered-state \
input-required-result-capability-check \
input-required-result-ignore-extra-params \
input-required-result-validate-input \
; do
npx -y "@modelcontextprotocol/conformance@${DRAFT_CONFORMANCE_VERSION}" server \
--url http://127.0.0.1:8002/mcp \
--scenario "$scenario" \
-o conformance-results
done

- name: Stop draft conformance server
if: always()
run: kill "$(cat draft-server.pid)" 2>/dev/null || true


- name: Stop conformance server
if: always()
run: kill "$(cat server.pid)" 2>/dev/null || true
Expand Down Expand Up @@ -98,6 +154,14 @@ jobs:
--spec-version 2025-11-25 \
-o conformance-client-results/full

# SEP-2322 MRTR client scenario (spec 2026-07-28).
- name: Run SEP-2322 MRTR client scenario
run: |
npx -y "@modelcontextprotocol/conformance@${DRAFT_CONFORMANCE_VERSION}" client \
--command "$(pwd)/target/debug/conformance-client" \
--scenario sep-2322-client-request-state \
-o conformance-client-results/mrtr

- name: Upload results
if: always()
uses: actions/upload-artifact@v7
Expand Down
1 change: 1 addition & 0 deletions conformance/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ rmcp = { path = "../crates/rmcp", features = [
"client",
"elicitation",
"auth",
"request-state",
"transport-streamable-http-server",
"transport-streamable-http-client-reqwest",
] }
Expand Down
94 changes: 93 additions & 1 deletion conformance/src/bin/client.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use rmcp::{
ClientHandler, ErrorData, RoleClient, ServiceExt,
model::*,
service::RequestContext,
service::{RequestContext, serve_directly},
transport::{
AuthClient, AuthorizationManager, StreamableHttpClientTransport,
auth::{AuthorizationCallback, OAuthState},
Expand Down Expand Up @@ -824,6 +824,97 @@ async fn run_elicitation_defaults_client(server_url: &str) -> anyhow::Result<()>
Ok(())
}

/// A minimal stateless client transport: every outgoing message is one HTTP
/// POST and the JSON response body (if any) is queued for `receive()`.
///
/// The SEP-2322 client scenario's mock server speaks the stateless lifecycle
/// (no `initialize` handshake, plain JSON responses), which the session-based
/// `StreamableHttpClientTransport` cannot do. The transport is harness
/// plumbing; the behavior under test — the SDK's MRTR retry driver — runs
/// unchanged on top of it.
struct StatelessHttpTransport {
http: reqwest::Client,
uri: std::sync::Arc<str>,
tx: tokio::sync::mpsc::Sender<ServerJsonRpcMessage>,
rx: tokio::sync::mpsc::Receiver<ServerJsonRpcMessage>,
}

impl StatelessHttpTransport {
fn new(uri: &str) -> Self {
let (tx, rx) = tokio::sync::mpsc::channel(16);
Self {
http: reqwest::Client::new(),
uri: uri.into(),
tx,
rx,
}
}
}

impl rmcp::transport::Transport<RoleClient> for StatelessHttpTransport {
type Error = std::io::Error;

fn send(
&mut self,
item: rmcp::model::ClientJsonRpcMessage,
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send + 'static {
let http = self.http.clone();
let uri = self.uri.clone();
let tx = self.tx.clone();
async move {
let response = http
.post(uri.as_ref())
.header("MCP-Protocol-Version", "2026-07-28")
.json(&item)
.send()
.await
.map_err(std::io::Error::other)?;
match response.json::<ServerJsonRpcMessage>().await {
Ok(message) => {
let _ = tx.send(message).await;
}
Err(_) => {
// No JSON-RPC body (e.g. 202/204 for notifications).
}
}
Ok(())
}
}

async fn receive(&mut self) -> Option<ServerJsonRpcMessage> {
self.rx.recv().await
}

async fn close(&mut self) -> Result<(), Self::Error> {
Ok(())
}
}

/// A stateless-lifecycle client: the scenario's server has no `initialize`
/// handler, so skip the handshake with `serve_directly`, list the tools, and
/// call each one via the high-level `call_tool` helper (which drives SEP-2322
/// `input_required` retry rounds when the server requests them). Used by the
/// `sep-2322-client-request-state` scenario, whose mock server verifies
/// requestState echo, fresh JSON-RPC ids on retry, state omission, isolation
/// between tools, and the `resultType` default.
async fn run_stateless_client(server_url: &str) -> anyhow::Result<()> {
let transport = StatelessHttpTransport::new(server_url);
let peer_info = InitializeResult::new(ServerCapabilities::builder().enable_tools().build())
.with_protocol_version(ProtocolVersion::V_2026_07_28);
let client = serve_directly(FullClientHandler, transport, Some(peer_info));

let tools = client.list_tools(Default::default()).await?;
tracing::debug!("Listed {} tools", tools.tools.len());
for tool in &tools.tools {
let result = client
.call_tool(CallToolRequestParams::new(tool.name.clone()))
.await;
tracing::debug!("Called {}: {:?}", tool.name, result.is_ok());
}
client.cancel().await?;
Ok(())
}

async fn run_sse_retry_client(server_url: &str) -> anyhow::Result<()> {
let transport = StreamableHttpClientTransport::from_uri(server_url);
let client = BasicClientHandler.serve(transport).await?;
Expand Down Expand Up @@ -869,6 +960,7 @@ async fn main() -> anyhow::Result<()> {
run_elicitation_defaults_client(&server_url).await?
}
"sse-retry" => run_sse_retry_client(&server_url).await?,
"sep-2322-client-request-state" => run_stateless_client(&server_url).await?,

// Auth scenarios - standard OAuth flow
"auth/metadata-default"
Expand Down
Loading