test: enable supported draft SEP coverage (#971)
Some checks failed
CI / Lint Commit Messages (push) Has been cancelled
CI / Code Formatting (push) Has been cancelled
CI / Lint with Clippy (push) Has been cancelled
CI / SemVer Check (push) Has been cancelled
CI / Public API Check (push) Has been cancelled
CI / spell check with typos (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
CI / Run Tests (no local feature) (push) Has been cancelled
CI / Code Coverage (push) Has been cancelled
CI / Example test (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / Generate Documentation (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
Conformance / server (push) Has been cancelled
Conformance / client (push) Has been cancelled
Release-plz / Release-plz release (push) Has been cancelled
Release-plz / Release-plz PR (push) Has been cancelled
CI / Release crates (push) Has been cancelled
Some checks failed
CI / Lint Commit Messages (push) Has been cancelled
CI / Code Formatting (push) Has been cancelled
CI / Lint with Clippy (push) Has been cancelled
CI / SemVer Check (push) Has been cancelled
CI / Public API Check (push) Has been cancelled
CI / spell check with typos (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
CI / Run Tests (no local feature) (push) Has been cancelled
CI / Code Coverage (push) Has been cancelled
CI / Example test (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / Generate Documentation (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
Conformance / server (push) Has been cancelled
Conformance / client (push) Has been cancelled
Release-plz / Release-plz release (push) Has been cancelled
Release-plz / Release-plz PR (push) Has been cancelled
CI / Release crates (push) Has been cancelled
* test: enable supported draft SEP coverage * test: add SEP-2322 MRTR conformance scenarios * refactor: rename run_mrtr_client to run_stateless_client
This commit is contained in:
parent
d8331d9442
commit
3662d20ac2
4 changed files with 586 additions and 8 deletions
64
.github/workflows/conformance.yml
vendored
64
.github/workflows/conformance.yml
vendored
|
|
@ -14,6 +14,7 @@ concurrency:
|
||||||
env:
|
env:
|
||||||
# Pinned for reproducible runs; bump deliberately when the suite updates.
|
# Pinned for reproducible runs; bump deliberately when the suite updates.
|
||||||
CONFORMANCE_VERSION: "0.1.16"
|
CONFORMANCE_VERSION: "0.1.16"
|
||||||
|
DRAFT_CONFORMANCE_VERSION: "0.2.0-alpha.9"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
server:
|
server:
|
||||||
|
|
@ -64,6 +65,61 @@ jobs:
|
||||||
-o conformance-results
|
-o conformance-results
|
||||||
done
|
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
|
- name: Stop conformance server
|
||||||
if: always()
|
if: always()
|
||||||
run: kill "$(cat server.pid)" 2>/dev/null || true
|
run: kill "$(cat server.pid)" 2>/dev/null || true
|
||||||
|
|
@ -98,6 +154,14 @@ jobs:
|
||||||
--spec-version 2025-11-25 \
|
--spec-version 2025-11-25 \
|
||||||
-o conformance-client-results/full
|
-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
|
- name: Upload results
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ rmcp = { path = "../crates/rmcp", features = [
|
||||||
"client",
|
"client",
|
||||||
"elicitation",
|
"elicitation",
|
||||||
"auth",
|
"auth",
|
||||||
|
"request-state",
|
||||||
"transport-streamable-http-server",
|
"transport-streamable-http-server",
|
||||||
"transport-streamable-http-client-reqwest",
|
"transport-streamable-http-client-reqwest",
|
||||||
] }
|
] }
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use rmcp::{
|
use rmcp::{
|
||||||
ClientHandler, ErrorData, RoleClient, ServiceExt,
|
ClientHandler, ErrorData, RoleClient, ServiceExt,
|
||||||
model::*,
|
model::*,
|
||||||
service::RequestContext,
|
service::{RequestContext, serve_directly},
|
||||||
transport::{
|
transport::{
|
||||||
AuthClient, AuthorizationManager, StreamableHttpClientTransport,
|
AuthClient, AuthorizationManager, StreamableHttpClientTransport,
|
||||||
auth::{AuthorizationCallback, OAuthState},
|
auth::{AuthorizationCallback, OAuthState},
|
||||||
|
|
@ -824,6 +824,97 @@ async fn run_elicitation_defaults_client(server_url: &str) -> anyhow::Result<()>
|
||||||
Ok(())
|
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<()> {
|
async fn run_sse_retry_client(server_url: &str) -> anyhow::Result<()> {
|
||||||
let transport = StreamableHttpClientTransport::from_uri(server_url);
|
let transport = StreamableHttpClientTransport::from_uri(server_url);
|
||||||
let client = BasicClientHandler.serve(transport).await?;
|
let client = BasicClientHandler.serve(transport).await?;
|
||||||
|
|
@ -869,6 +960,7 @@ async fn main() -> anyhow::Result<()> {
|
||||||
run_elicitation_defaults_client(&server_url).await?
|
run_elicitation_defaults_client(&server_url).await?
|
||||||
}
|
}
|
||||||
"sse-retry" => run_sse_retry_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 scenarios - standard OAuth flow
|
||||||
"auth/metadata-default"
|
"auth/metadata-default"
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ use tracing_subscriber::EnvFilter;
|
||||||
const TEST_IMAGE_DATA: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==";
|
const TEST_IMAGE_DATA: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==";
|
||||||
// Small base64-encoded WAV (silence)
|
// Small base64-encoded WAV (silence)
|
||||||
const TEST_AUDIO_DATA: &str = "UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=";
|
const TEST_AUDIO_DATA: &str = "UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=";
|
||||||
|
const CACHE_TTL_MS: u64 = 60_000;
|
||||||
|
|
||||||
/// Helper to convert a serde_json::Value (must be an object) into a JsonObject
|
/// Helper to convert a serde_json::Value (must be an object) into a JsonObject
|
||||||
fn json_object(v: Value) -> JsonObject {
|
fn json_object(v: Value) -> JsonObject {
|
||||||
|
|
@ -27,10 +28,15 @@ fn json_object(v: Value) -> JsonObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Signing key for SEP-2322 `requestState` sealing. A fixed key is fine for a
|
||||||
|
/// conformance harness; real servers must load a secret out of clients' reach.
|
||||||
|
const REQUEST_STATE_KEY: &[u8] = b"rust-sdk-conformance-request-state-key!!";
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct ConformanceServer {
|
struct ConformanceServer {
|
||||||
subscriptions: Arc<Mutex<HashSet<String>>>,
|
subscriptions: Arc<Mutex<HashSet<String>>>,
|
||||||
log_level: Arc<Mutex<LoggingLevel>>,
|
log_level: Arc<Mutex<LoggingLevel>>,
|
||||||
|
request_state_codec: RequestStateCodec,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ConformanceServer {
|
impl ConformanceServer {
|
||||||
|
|
@ -38,6 +44,318 @@ impl ConformanceServer {
|
||||||
Self {
|
Self {
|
||||||
subscriptions: Arc::new(Mutex::new(HashSet::new())),
|
subscriptions: Arc::new(Mutex::new(HashSet::new())),
|
||||||
log_level: Arc::new(Mutex::new(LoggingLevel::Debug)),
|
log_level: Arc::new(Mutex::new(LoggingLevel::Debug)),
|
||||||
|
request_state_codec: RequestStateCodec::new(REQUEST_STATE_KEY),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SEP-2322 MRTR (InputRequiredResult) helpers ────────────────────────────
|
||||||
|
|
||||||
|
fn mrtr_elicitation_request(message: &str, properties: Value, required: Value) -> InputRequest {
|
||||||
|
InputRequest::Elicitation(ElicitRequest::new(
|
||||||
|
ElicitRequestParams::FormElicitationParams {
|
||||||
|
meta: None,
|
||||||
|
message: message.into(),
|
||||||
|
requested_schema: serde_json::from_value(json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": properties,
|
||||||
|
"required": required,
|
||||||
|
}))
|
||||||
|
.expect("valid elicitation schema"),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mrtr_sampling_request(prompt: &str) -> InputRequest {
|
||||||
|
InputRequest::CreateMessage(CreateMessageRequest::new(CreateMessageRequestParams::new(
|
||||||
|
vec![SamplingMessage::user_text(prompt)],
|
||||||
|
100,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mrtr_list_roots_request() -> InputRequest {
|
||||||
|
InputRequest::ListRoots(ListRootsRequest::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An input response is usable when it is a JSON object (an `ElicitResult`,
|
||||||
|
/// `CreateMessageResult`, or `ListRootsResult` shape). Anything else (e.g. a
|
||||||
|
/// bare number) is treated as missing so the server re-requests it.
|
||||||
|
fn mrtr_response<'a>(
|
||||||
|
responses: Option<&'a InputResponses>,
|
||||||
|
key: &str,
|
||||||
|
) -> Option<&'a serde_json::Map<String, Value>> {
|
||||||
|
responses
|
||||||
|
.and_then(|r| r.get(key))
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConformanceServer {
|
||||||
|
fn mrtr_tampered_state_error() -> ErrorData {
|
||||||
|
ErrorData::invalid_params("requestState failed integrity verification", None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SEP-2322 test tools. Each returns an `InputRequiredResult` until the
|
||||||
|
/// client retries with the expected `inputResponses` (and, where used, the
|
||||||
|
/// echoed `requestState`).
|
||||||
|
///
|
||||||
|
/// `meta` is the request's `_meta`, which the service loop moves out of the
|
||||||
|
/// params and into the `RequestContext`.
|
||||||
|
async fn call_mrtr_tool(
|
||||||
|
&self,
|
||||||
|
request: CallToolRequestParams,
|
||||||
|
meta: &Meta,
|
||||||
|
) -> Result<CallToolResponse, ErrorData> {
|
||||||
|
let responses = request.input_responses.as_ref();
|
||||||
|
match request.name.as_ref() {
|
||||||
|
"test_input_required_result_elicitation" => {
|
||||||
|
match mrtr_response(responses, "user_name") {
|
||||||
|
Some(response) => {
|
||||||
|
let name = response
|
||||||
|
.get("content")
|
||||||
|
.and_then(|c| c.get("name"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("friend");
|
||||||
|
Ok(CallToolResult::success(vec![ContentBlock::text(format!(
|
||||||
|
"Hello, {name}!"
|
||||||
|
))])
|
||||||
|
.into())
|
||||||
|
}
|
||||||
|
// Initial call, or a retry with missing/invalid responses:
|
||||||
|
// (re-)request the input per the SEP's recommendation.
|
||||||
|
None => {
|
||||||
|
let mut requests = InputRequests::new();
|
||||||
|
requests.insert(
|
||||||
|
"user_name".into(),
|
||||||
|
mrtr_elicitation_request(
|
||||||
|
"What is your name?",
|
||||||
|
json!({ "name": { "type": "string" } }),
|
||||||
|
json!(["name"]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Ok(InputRequiredResult::from_input_requests(requests).into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"test_input_required_result_sampling" => {
|
||||||
|
match mrtr_response(responses, "capital_question") {
|
||||||
|
Some(response) => {
|
||||||
|
let text = response
|
||||||
|
.get("content")
|
||||||
|
.and_then(|c| c.get("text"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("(no sampling text)");
|
||||||
|
Ok(CallToolResult::success(vec![ContentBlock::text(format!(
|
||||||
|
"Sampling response: {text}"
|
||||||
|
))])
|
||||||
|
.into())
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let mut requests = InputRequests::new();
|
||||||
|
requests.insert(
|
||||||
|
"capital_question".into(),
|
||||||
|
mrtr_sampling_request("What is the capital of France?"),
|
||||||
|
);
|
||||||
|
Ok(InputRequiredResult::from_input_requests(requests).into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"test_input_required_result_list_roots" => {
|
||||||
|
match mrtr_response(responses, "client_roots") {
|
||||||
|
Some(response) => {
|
||||||
|
let roots = response
|
||||||
|
.get("roots")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|roots| {
|
||||||
|
roots
|
||||||
|
.iter()
|
||||||
|
.filter_map(|r| r.get("uri").and_then(Value::as_str))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
Ok(CallToolResult::success(vec![ContentBlock::text(format!(
|
||||||
|
"Client roots: [{roots}]"
|
||||||
|
))])
|
||||||
|
.into())
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let mut requests = InputRequests::new();
|
||||||
|
requests.insert("client_roots".into(), mrtr_list_roots_request());
|
||||||
|
Ok(InputRequiredResult::from_input_requests(requests).into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"test_input_required_result_request_state"
|
||||||
|
| "test_input_required_result_tampered_state" => {
|
||||||
|
match request.request_state.as_deref() {
|
||||||
|
// Initial call: request confirmation and seal our progress.
|
||||||
|
None => {
|
||||||
|
let sealed = self
|
||||||
|
.request_state_codec
|
||||||
|
.seal_json(&json!({ "stage": "confirm" }))
|
||||||
|
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
|
||||||
|
let mut requests = InputRequests::new();
|
||||||
|
requests.insert(
|
||||||
|
"confirm".into(),
|
||||||
|
mrtr_elicitation_request(
|
||||||
|
"Please confirm",
|
||||||
|
json!({ "ok": { "type": "boolean" } }),
|
||||||
|
json!(["ok"]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Ok(InputRequiredResult::new(Some(requests), Some(sealed)).into())
|
||||||
|
}
|
||||||
|
// Retry: the echoed state is untrusted input and MUST pass
|
||||||
|
// integrity verification before we act on it.
|
||||||
|
Some(sealed) => {
|
||||||
|
self.request_state_codec
|
||||||
|
.open(sealed)
|
||||||
|
.map_err(|_| Self::mrtr_tampered_state_error())?;
|
||||||
|
Ok(
|
||||||
|
CallToolResult::success(vec![ContentBlock::text(
|
||||||
|
"Confirmed: state-ok",
|
||||||
|
)])
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"test_input_required_result_multiple_inputs" => {
|
||||||
|
if let Some(sealed) = request.request_state.as_deref() {
|
||||||
|
self.request_state_codec
|
||||||
|
.open(sealed)
|
||||||
|
.map_err(|_| Self::mrtr_tampered_state_error())?;
|
||||||
|
}
|
||||||
|
let all_present = mrtr_response(responses, "user_name").is_some()
|
||||||
|
&& mrtr_response(responses, "greeting").is_some()
|
||||||
|
&& mrtr_response(responses, "client_roots").is_some();
|
||||||
|
if all_present && request.request_state.is_some() {
|
||||||
|
Ok(
|
||||||
|
CallToolResult::success(vec![ContentBlock::text("All inputs received")])
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let sealed = self
|
||||||
|
.request_state_codec
|
||||||
|
.seal_json(&json!({ "stage": "gather" }))
|
||||||
|
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
|
||||||
|
let mut requests = InputRequests::new();
|
||||||
|
requests.insert(
|
||||||
|
"user_name".into(),
|
||||||
|
mrtr_elicitation_request(
|
||||||
|
"What is your name?",
|
||||||
|
json!({ "name": { "type": "string" } }),
|
||||||
|
json!(["name"]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
requests.insert(
|
||||||
|
"greeting".into(),
|
||||||
|
mrtr_sampling_request("Generate a greeting"),
|
||||||
|
);
|
||||||
|
requests.insert("client_roots".into(), mrtr_list_roots_request());
|
||||||
|
Ok(InputRequiredResult::new(Some(requests), Some(sealed)).into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"test_input_required_result_multi_round" => {
|
||||||
|
let round = match request.request_state.as_deref() {
|
||||||
|
None => 0,
|
||||||
|
Some(sealed) => {
|
||||||
|
let state: Value = self
|
||||||
|
.request_state_codec
|
||||||
|
.open_json(sealed)
|
||||||
|
.map_err(|_| Self::mrtr_tampered_state_error())?;
|
||||||
|
state.get("round").and_then(Value::as_i64).unwrap_or(0)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match round {
|
||||||
|
0 => {
|
||||||
|
let sealed = self
|
||||||
|
.request_state_codec
|
||||||
|
.seal_json(&json!({ "round": 1 }))
|
||||||
|
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
|
||||||
|
let mut requests = InputRequests::new();
|
||||||
|
requests.insert(
|
||||||
|
"step1".into(),
|
||||||
|
mrtr_elicitation_request(
|
||||||
|
"Step 1: What is your name?",
|
||||||
|
json!({ "name": { "type": "string" } }),
|
||||||
|
json!(["name"]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Ok(InputRequiredResult::new(Some(requests), Some(sealed)).into())
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
let sealed = self
|
||||||
|
.request_state_codec
|
||||||
|
.seal_json(&json!({ "round": 2 }))
|
||||||
|
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
|
||||||
|
let mut requests = InputRequests::new();
|
||||||
|
requests.insert(
|
||||||
|
"step2".into(),
|
||||||
|
mrtr_elicitation_request(
|
||||||
|
"Step 2: What is your favorite color?",
|
||||||
|
json!({ "color": { "type": "string" } }),
|
||||||
|
json!(["color"]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Ok(InputRequiredResult::new(Some(requests), Some(sealed)).into())
|
||||||
|
}
|
||||||
|
_ => Ok(CallToolResult::success(vec![ContentBlock::text(
|
||||||
|
"Multi-round flow complete",
|
||||||
|
)])
|
||||||
|
.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"test_input_required_result_capabilities" => {
|
||||||
|
if responses.is_some() {
|
||||||
|
return Ok(CallToolResult::success(vec![ContentBlock::text(
|
||||||
|
"Capability-aware flow complete",
|
||||||
|
)])
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
// Per SEP-2322, only request inputs the client declared support
|
||||||
|
// for in `_meta['io.modelcontextprotocol/clientCapabilities']`.
|
||||||
|
let capabilities = meta.client_capabilities().unwrap_or_default();
|
||||||
|
let mut requests = InputRequests::new();
|
||||||
|
if capabilities.elicitation.is_some() {
|
||||||
|
requests.insert(
|
||||||
|
"user_name".into(),
|
||||||
|
mrtr_elicitation_request(
|
||||||
|
"What is your name?",
|
||||||
|
json!({ "name": { "type": "string" } }),
|
||||||
|
json!(["name"]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if capabilities.sampling.is_some() {
|
||||||
|
requests.insert(
|
||||||
|
"greeting".into(),
|
||||||
|
mrtr_sampling_request("Generate a greeting"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if capabilities.roots.is_some() {
|
||||||
|
requests.insert("client_roots".into(), mrtr_list_roots_request());
|
||||||
|
}
|
||||||
|
if requests.is_empty() {
|
||||||
|
Ok(CallToolResult::success(vec![ContentBlock::text(
|
||||||
|
"Client declared no MRTR-capable capabilities",
|
||||||
|
)])
|
||||||
|
.into())
|
||||||
|
} else {
|
||||||
|
Ok(InputRequiredResult::from_input_requests(requests).into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => Err(ErrorData::invalid_params(
|
||||||
|
format!("Unknown tool: {}", request.name),
|
||||||
|
None,
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -45,7 +363,7 @@ impl ConformanceServer {
|
||||||
impl ServerHandler for ConformanceServer {
|
impl ServerHandler for ConformanceServer {
|
||||||
async fn initialize(
|
async fn initialize(
|
||||||
&self,
|
&self,
|
||||||
_request: InitializeRequestParams,
|
request: InitializeRequestParams,
|
||||||
_cx: RequestContext<RoleServer>,
|
_cx: RequestContext<RoleServer>,
|
||||||
) -> Result<InitializeResult, ErrorData> {
|
) -> Result<InitializeResult, ErrorData> {
|
||||||
Ok(InitializeResult::new(
|
Ok(InitializeResult::new(
|
||||||
|
|
@ -56,6 +374,7 @@ impl ServerHandler for ConformanceServer {
|
||||||
.enable_logging()
|
.enable_logging()
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
|
.with_protocol_version(request.protocol_version)
|
||||||
.with_server_info(Implementation::new("rust-conformance-server", "0.1.0"))
|
.with_server_info(Implementation::new("rust-conformance-server", "0.1.0"))
|
||||||
.with_instructions("Rust MCP conformance test server"))
|
.with_instructions("Rust MCP conformance test server"))
|
||||||
}
|
}
|
||||||
|
|
@ -203,10 +522,57 @@ impl ServerHandler for ConformanceServer {
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
// SEP-2322 MRTR test tools; all take no arguments.
|
||||||
|
let mrtr_tools = [
|
||||||
|
(
|
||||||
|
"test_input_required_result_elicitation",
|
||||||
|
"Requires an elicitation input via InputRequiredResult (SEP-2322)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"test_input_required_result_sampling",
|
||||||
|
"Requires a sampling input via InputRequiredResult (SEP-2322)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"test_input_required_result_list_roots",
|
||||||
|
"Requires a roots/list input via InputRequiredResult (SEP-2322)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"test_input_required_result_request_state",
|
||||||
|
"Round-trips integrity-protected requestState (SEP-2322)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"test_input_required_result_multiple_inputs",
|
||||||
|
"Requires elicitation + sampling + roots inputs in one round (SEP-2322)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"test_input_required_result_multi_round",
|
||||||
|
"Drives multiple input_required rounds with evolving requestState (SEP-2322)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"test_input_required_result_tampered_state",
|
||||||
|
"Rejects tampered requestState with a JSON-RPC error (SEP-2322)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"test_input_required_result_capabilities",
|
||||||
|
"Only requests inputs for declared client capabilities (SEP-2322)",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
let tools = tools
|
||||||
|
.into_iter()
|
||||||
|
.chain(mrtr_tools.into_iter().map(|(name, description)| {
|
||||||
|
Tool::new(
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
json_object(json!({ "type": "object", "properties": {} })),
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
.collect();
|
||||||
Ok(ListToolsResult {
|
Ok(ListToolsResult {
|
||||||
tools,
|
tools,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
}
|
||||||
|
.with_ttl_ms(CACHE_TTL_MS)
|
||||||
|
.with_cache_scope(CacheScope::Public))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn call_tool(
|
async fn call_tool(
|
||||||
|
|
@ -214,6 +580,9 @@ impl ServerHandler for ConformanceServer {
|
||||||
request: CallToolRequestParams,
|
request: CallToolRequestParams,
|
||||||
cx: RequestContext<RoleServer>,
|
cx: RequestContext<RoleServer>,
|
||||||
) -> Result<CallToolResponse, ErrorData> {
|
) -> Result<CallToolResponse, ErrorData> {
|
||||||
|
if request.name.starts_with("test_input_required_result_") {
|
||||||
|
return self.call_mrtr_tool(request, &cx.meta).await;
|
||||||
|
}
|
||||||
let args = request.arguments.unwrap_or_default();
|
let args = request.arguments.unwrap_or_default();
|
||||||
let result = match request.name.as_ref() {
|
let result = match request.name.as_ref() {
|
||||||
"test_simple_text" => Ok(CallToolResult::success(vec![ContentBlock::text(
|
"test_simple_text" => Ok(CallToolResult::success(vec![ContentBlock::text(
|
||||||
|
|
@ -549,7 +918,9 @@ impl ServerHandler for ConformanceServer {
|
||||||
.with_mime_type("image/png"),
|
.with_mime_type("image/png"),
|
||||||
],
|
],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
}
|
||||||
|
.with_ttl_ms(CACHE_TTL_MS)
|
||||||
|
.with_cache_scope(CacheScope::Public))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn read_resource(
|
async fn read_resource(
|
||||||
|
|
@ -600,7 +971,13 @@ impl ServerHandler for ConformanceServer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
result.map(Into::into)
|
result
|
||||||
|
.map(|result| {
|
||||||
|
result
|
||||||
|
.with_ttl_ms(CACHE_TTL_MS)
|
||||||
|
.with_cache_scope(CacheScope::Public)
|
||||||
|
})
|
||||||
|
.map(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_resource_templates(
|
async fn list_resource_templates(
|
||||||
|
|
@ -615,7 +992,9 @@ impl ServerHandler for ConformanceServer {
|
||||||
.with_mime_type("application/json"),
|
.with_mime_type("application/json"),
|
||||||
],
|
],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
}
|
||||||
|
.with_ttl_ms(CACHE_TTL_MS)
|
||||||
|
.with_cache_scope(CacheScope::Public))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn subscribe(
|
async fn subscribe(
|
||||||
|
|
@ -672,9 +1051,18 @@ impl ServerHandler for ConformanceServer {
|
||||||
Some("A test prompt that includes an image"),
|
Some("A test prompt that includes an image"),
|
||||||
None,
|
None,
|
||||||
),
|
),
|
||||||
|
Prompt::new(
|
||||||
|
"test_input_required_result_prompt",
|
||||||
|
Some(
|
||||||
|
"A prompt that requires elicitation input via InputRequiredResult (SEP-2322)",
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
}
|
||||||
|
.with_ttl_ms(CACHE_TTL_MS)
|
||||||
|
.with_cache_scope(CacheScope::Public))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_prompt(
|
async fn get_prompt(
|
||||||
|
|
@ -682,6 +1070,36 @@ impl ServerHandler for ConformanceServer {
|
||||||
request: GetPromptRequestParams,
|
request: GetPromptRequestParams,
|
||||||
_cx: RequestContext<RoleServer>,
|
_cx: RequestContext<RoleServer>,
|
||||||
) -> Result<GetPromptResponse, ErrorData> {
|
) -> Result<GetPromptResponse, ErrorData> {
|
||||||
|
// SEP-2322: InputRequiredResult on a non-tool request (prompts/get).
|
||||||
|
if request.name == "test_input_required_result_prompt" {
|
||||||
|
return match mrtr_response(request.input_responses.as_ref(), "user_context") {
|
||||||
|
Some(response) => {
|
||||||
|
let context = response
|
||||||
|
.get("content")
|
||||||
|
.and_then(|c| c.get("context"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("(no context)");
|
||||||
|
Ok(GetPromptResult::new(vec![PromptMessage::new_text(
|
||||||
|
Role::User,
|
||||||
|
format!("Prompt with elicited context: {context}"),
|
||||||
|
)])
|
||||||
|
.with_description("A prompt built from elicited context")
|
||||||
|
.into())
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let mut requests = InputRequests::new();
|
||||||
|
requests.insert(
|
||||||
|
"user_context".into(),
|
||||||
|
mrtr_elicitation_request(
|
||||||
|
"What context should the prompt use?",
|
||||||
|
json!({ "context": { "type": "string" } }),
|
||||||
|
json!(["context"]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Ok(InputRequiredResult::from_input_requests(requests).into())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
let result = match request.name.as_str() {
|
let result = match request.name.as_str() {
|
||||||
"test_simple_prompt" => Ok(GetPromptResult::new(vec![PromptMessage::new_text(
|
"test_simple_prompt" => Ok(GetPromptResult::new(vec![PromptMessage::new_text(
|
||||||
Role::User,
|
Role::User,
|
||||||
|
|
@ -782,7 +1200,10 @@ async fn main() -> anyhow::Result<()> {
|
||||||
tracing::info!("Starting conformance server on {}", bind_addr);
|
tracing::info!("Starting conformance server on {}", bind_addr);
|
||||||
|
|
||||||
let server = ConformanceServer::new();
|
let server = ConformanceServer::new();
|
||||||
let config = StreamableHttpServerConfig::default();
|
let stateless = std::env::var_os("STATELESS").is_some();
|
||||||
|
let config = StreamableHttpServerConfig::default()
|
||||||
|
.with_stateful_mode(!stateless)
|
||||||
|
.with_json_response(stateless);
|
||||||
let service = StreamableHttpService::new(
|
let service = StreamableHttpService::new(
|
||||||
move || Ok(server.clone()),
|
move || Ok(server.clone()),
|
||||||
LocalSessionManager::default().into(),
|
LocalSessionManager::default().into(),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue