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

* 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:
Dale Seo 2026-07-10 17:21:07 -04:00 committed by GitHub
parent d8331d9442
commit 3662d20ac2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 586 additions and 8 deletions

View file

@ -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

View file

@ -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",
] } ] }

View file

@ -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"

View file

@ -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(),