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:
|
||||
# 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:
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ rmcp = { path = "../crates/rmcp", features = [
|
|||
"client",
|
||||
"elicitation",
|
||||
"auth",
|
||||
"request-state",
|
||||
"transport-streamable-http-server",
|
||||
"transport-streamable-http-client-reqwest",
|
||||
] }
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use rmcp::{
|
||||
ClientHandler, ErrorData, RoleClient, ServiceExt,
|
||||
model::*,
|
||||
service::RequestContext,
|
||||
service::{RequestContext, serve_directly},
|
||||
transport::{
|
||||
AuthClient, AuthorizationManager, StreamableHttpClientTransport,
|
||||
auth::{AuthorizationCallback, OAuthState},
|
||||
|
|
@ -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?;
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ use tracing_subscriber::EnvFilter;
|
|||
const TEST_IMAGE_DATA: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==";
|
||||
// Small base64-encoded WAV (silence)
|
||||
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
|
||||
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)]
|
||||
struct ConformanceServer {
|
||||
subscriptions: Arc<Mutex<HashSet<String>>>,
|
||||
log_level: Arc<Mutex<LoggingLevel>>,
|
||||
request_state_codec: RequestStateCodec,
|
||||
}
|
||||
|
||||
impl ConformanceServer {
|
||||
|
|
@ -38,6 +44,318 @@ impl ConformanceServer {
|
|||
Self {
|
||||
subscriptions: Arc::new(Mutex::new(HashSet::new())),
|
||||
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 {
|
||||
async fn initialize(
|
||||
&self,
|
||||
_request: InitializeRequestParams,
|
||||
request: InitializeRequestParams,
|
||||
_cx: RequestContext<RoleServer>,
|
||||
) -> Result<InitializeResult, ErrorData> {
|
||||
Ok(InitializeResult::new(
|
||||
|
|
@ -56,6 +374,7 @@ impl ServerHandler for ConformanceServer {
|
|||
.enable_logging()
|
||||
.build(),
|
||||
)
|
||||
.with_protocol_version(request.protocol_version)
|
||||
.with_server_info(Implementation::new("rust-conformance-server", "0.1.0"))
|
||||
.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 {
|
||||
tools,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
.with_ttl_ms(CACHE_TTL_MS)
|
||||
.with_cache_scope(CacheScope::Public))
|
||||
}
|
||||
|
||||
async fn call_tool(
|
||||
|
|
@ -214,6 +580,9 @@ impl ServerHandler for ConformanceServer {
|
|||
request: CallToolRequestParams,
|
||||
cx: RequestContext<RoleServer>,
|
||||
) -> 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 result = match request.name.as_ref() {
|
||||
"test_simple_text" => Ok(CallToolResult::success(vec![ContentBlock::text(
|
||||
|
|
@ -549,7 +918,9 @@ impl ServerHandler for ConformanceServer {
|
|||
.with_mime_type("image/png"),
|
||||
],
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
.with_ttl_ms(CACHE_TTL_MS)
|
||||
.with_cache_scope(CacheScope::Public))
|
||||
}
|
||||
|
||||
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(
|
||||
|
|
@ -615,7 +992,9 @@ impl ServerHandler for ConformanceServer {
|
|||
.with_mime_type("application/json"),
|
||||
],
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
.with_ttl_ms(CACHE_TTL_MS)
|
||||
.with_cache_scope(CacheScope::Public))
|
||||
}
|
||||
|
||||
async fn subscribe(
|
||||
|
|
@ -672,9 +1051,18 @@ impl ServerHandler for ConformanceServer {
|
|||
Some("A test prompt that includes an image"),
|
||||
None,
|
||||
),
|
||||
Prompt::new(
|
||||
"test_input_required_result_prompt",
|
||||
Some(
|
||||
"A prompt that requires elicitation input via InputRequiredResult (SEP-2322)",
|
||||
),
|
||||
None,
|
||||
),
|
||||
],
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
.with_ttl_ms(CACHE_TTL_MS)
|
||||
.with_cache_scope(CacheScope::Public))
|
||||
}
|
||||
|
||||
async fn get_prompt(
|
||||
|
|
@ -682,6 +1070,36 @@ impl ServerHandler for ConformanceServer {
|
|||
request: GetPromptRequestParams,
|
||||
_cx: RequestContext<RoleServer>,
|
||||
) -> 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() {
|
||||
"test_simple_prompt" => Ok(GetPromptResult::new(vec![PromptMessage::new_text(
|
||||
Role::User,
|
||||
|
|
@ -782,7 +1200,10 @@ async fn main() -> anyhow::Result<()> {
|
|||
tracing::info!("Starting conformance server on {}", bind_addr);
|
||||
|
||||
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(
|
||||
move || Ok(server.clone()),
|
||||
LocalSessionManager::default().into(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue