use std::pin::Pin;
use std::sync::Arc;
use protocol_droid::application::client::{Client, ClientTransport, TransportError};
use protocol_droid::application::server::context::{CallbackExchange, ExchangeError, PeerSession};
use protocol_droid::application::server::{
DispatchError, Dispatcher, RequestContext, Server, ToolHandler,
};
use protocol_droid::application::session::{FrameSink, SinkError};
use protocol_droid::domain::feature::{Negotiation, resolve};
use protocol_droid::domain::ir::{JsonObject, JsonObjectError};
use protocol_droid::domain::registry::{AuthContext, Primitive, Registered, Scope, ScopeSet, Tool};
use protocol_droid::domain::session::{CancellationToken, SessionId};
use protocol_droid::domain::{CapabilitySet, Feature, ProtocolVersion, RequestId, TransportKind};
const REVISIONS: [ProtocolVersion; 5] = [
ProtocolVersion::V2024_11_05,
ProtocolVersion::V2025_03_26,
ProtocolVersion::V2025_06_18,
ProtocolVersion::V2025_11_25,
ProtocolVersion::V2026_07_28,
];
const PUBLIC_TOOL: &str = "echo";
const RESTRICTED_TOOL: &str = "restricted_echo";
#[derive(Debug)]
enum Failure {
Message(String),
Fixture(JsonObjectError),
}
impl core::fmt::Display for Failure {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Message(text) => f.write_str(text),
Self::Fixture(error) => write!(f, "bad fixture: {error}"),
}
}
}
impl std::error::Error for Failure {}
impl From<String> for Failure {
fn from(text: String) -> Self {
Self::Message(text)
}
}
impl From<&str> for Failure {
fn from(text: &str) -> Self {
Self::Message(text.to_string())
}
}
impl From<JsonObjectError> for Failure {
fn from(error: JsonObjectError) -> Self {
Self::Fixture(error)
}
}
type TestError = Failure;
#[derive(Clone)]
struct InProcess {
dispatcher: Arc<Dispatcher>,
ctx: RequestContext,
}
impl ClientTransport for InProcess {
fn notify(
&self,
_method: &str,
_params: &JsonObject,
) -> Pin<Box<dyn std::future::Future<Output = Result<(), TransportError>> + Send + '_>> {
Box::pin(async { Ok(()) })
}
fn send(
&self,
method: &str,
params: &JsonObject,
) -> Pin<Box<dyn std::future::Future<Output = Result<JsonObject, TransportError>> + Send + '_>>
{
let dispatcher = self.dispatcher.clone();
let ctx = self.ctx.clone();
let method = method.to_string();
let params = params.clone();
Box::pin(async move {
dispatcher
.dispatch(&ctx, &method, ¶ms)
.await
.map_err(|error| TransportError::Unavailable(error.to_string()))
})
}
}
struct RefusingExchange;
impl CallbackExchange for RefusingExchange {
fn exchange(
&self,
method: &str,
_params: &JsonObject,
) -> impl std::future::Future<Output = Result<JsonObject, ExchangeError>> + Send {
let _ = method;
std::future::ready(Err(ExchangeError::new("no callbacks in this test")))
}
}
#[derive(Debug, Default)]
struct CountingSink {
frames: std::sync::Mutex<Vec<String>>,
}
impl FrameSink for CountingSink {
fn send(&self, frame: &str) -> impl std::future::Future<Output = Result<(), SinkError>> + Send {
if let Ok(mut frames) = self.frames.lock() {
frames.push(frame.to_string());
}
std::future::ready(Ok(()))
}
}
impl CountingSink {
fn written(&self) -> usize {
self.frames.lock().map_or(0, |frames| frames.len())
}
}
fn server() -> Result<Server, TestError> {
let public = Tool::new(PUBLIC_TOOL, r#"{"type":"object"}"#)?;
let restricted = Registered::from(Primitive::from(Tool::new(
RESTRICTED_TOOL,
r#"{"type":"object"}"#,
)?))
.requiring_scope(Scope::Read);
Ok(Server::builder("probe")
.tool(public, echo_handler())
.restricted_tool(restricted, echo_handler())
.build())
}
fn echo_handler() -> ToolHandler {
Arc::new(|_ctx, arguments| Box::pin(async move { arguments }))
}
fn server_side(
version: ProtocolVersion,
principal: AuthContext,
) -> (PeerSession, Arc<CountingSink>) {
let sink = Arc::new(CountingSink::default());
let peer = PeerSession::new(
version,
resolve(negotiation(version)),
SessionId::new(1),
TransportKind::Stdio,
Arc::new(RefusingExchange),
sink.clone(),
)
.with_auth(principal);
(peer, sink)
}
fn negotiation(version: ProtocolVersion) -> Negotiation {
Negotiation {
version,
client_caps: CapabilitySet::empty(),
server_caps: CapabilitySet::all_for(version),
transport: TransportKind::StreamableHttp,
}
}
fn connected(
v_client: ProtocolVersion,
v_server: ProtocolVersion,
principal: AuthContext,
) -> Result<Client, TestError> {
let (peer, _sink) = server_side(v_server, principal);
let ctx = request_context(&peer);
Ok(Client::connect(
negotiation(v_client),
Arc::new(InProcess {
dispatcher: Arc::new(Dispatcher::new(Arc::new(server()?))),
ctx,
}),
))
}
fn request_context(peer: &PeerSession) -> RequestContext {
RequestContext::for_request(peer, RequestId::text("r1"), CancellationToken::new(), None)
}
async fn exchange(
v_client: ProtocolVersion,
v_server: ProtocolVersion,
principal: AuthContext,
) -> Result<(), TestError> {
let client = connected(v_client, v_server, principal.clone())?;
let listed = client.list_tools().await.map_err(|error| {
Failure::from(format!(
"tools/list failed for {v_client} -> {v_server}: {error}"
))
})?;
let names = tool_names(&listed)?;
if !names.iter().any(|name| name == PUBLIC_TOOL) {
return Err(Failure::from(format!(
"{v_client} -> {v_server}: the public tool is missing from {names:?}"
)));
}
let expected = principal.principal() == "restricted";
if names.iter().any(|name| name == RESTRICTED_TOOL) != expected {
return Err(Failure::from(format!(
"{v_client} -> {v_server}: {} saw the restricted tool",
principal.principal()
)));
}
let arguments = JsonObject::new(r#"{"text":"quote \" and brace }","n":1}"#)?;
let called = client
.call_tool(PUBLIC_TOOL, &arguments)
.await
.map_err(|error| {
Failure::from(format!(
"tools/call failed for {v_client} -> {v_server}: {error}"
))
})?;
let sent: serde_json::Value = serde_json::from_str(arguments.as_str())
.map_err(|error| Failure::from(format!("the fixture is not JSON: {error}")))?;
let got: serde_json::Value = serde_json::from_str(called.as_str())
.map_err(|error| Failure::from(format!("the answer is not JSON: {error}")))?;
let got = strip_result_envelope(got);
if sent != got {
return Err(Failure::from(format!(
"{v_client} -> {v_server}: sent {sent}, got {got}"
)));
}
Ok(())
}
fn strip_result_envelope(result: serde_json::Value) -> serde_json::Value {
let serde_json::Value::Object(mut members) = result else {
return result;
};
for envelope_member in ["resultType", "ttlMs", "cacheScope"] {
members.remove(envelope_member);
}
if let Some(serde_json::Value::Object(meta)) = members.get_mut("_meta") {
meta.remove("io.modelcontextprotocol/serverInfo");
if meta.is_empty() {
members.remove("_meta");
}
}
serde_json::Value::Object(members)
}
fn tool_names(listing: &JsonObject) -> Result<Vec<String>, TestError> {
let parsed: serde_json::Value = serde_json::from_str(listing.as_str())
.map_err(|error| Failure::from(format!("the listing is not JSON: {error}")))?;
let tools = parsed
.get("tools")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| Failure::from("the listing has no tools array"))?;
Ok(tools
.iter()
.filter_map(|tool| tool.get("name"))
.filter_map(serde_json::Value::as_str)
.map(str::to_string)
.collect())
}
#[tokio::test]
async fn every_client_revision_against_every_server_revision() -> Result<(), TestError> {
for v_client in REVISIONS {
for v_server in REVISIONS {
exchange(
v_client,
v_server,
AuthContext::new("restricted", ScopeSet::all()),
)
.await?;
}
}
Ok(())
}
#[tokio::test]
async fn every_pair_also_works_for_a_caller_holding_no_scope() -> Result<(), TestError> {
for v_client in REVISIONS {
for v_server in REVISIONS {
exchange(v_client, v_server, AuthContext::anonymous()).await?;
}
}
Ok(())
}
#[tokio::test]
async fn a_tool_missing_from_the_listing_is_also_not_callable() -> Result<(), TestError> {
let version = ProtocolVersion::V2025_06_18;
let client = connected(
version,
version,
AuthContext::new("nobody", ScopeSet::empty()),
)?;
let listed = client
.list_tools()
.await
.map_err(|error| Failure::from(error.to_string()))?;
let names = tool_names(&listed)?;
if names.iter().any(|name| name == RESTRICTED_TOOL) {
return Err(Failure::from(
"a caller with no scope saw the restricted tool in the listing",
));
}
client
.call_tool(RESTRICTED_TOOL, &JsonObject::default())
.await
.map_or(Ok(()), |answer| {
Err(Failure::from(format!(
"a caller with no scope called the restricted tool: {answer}"
)))
})
}
#[tokio::test]
async fn a_tool_requiring_a_missing_feature_is_refused() -> Result<(), TestError> {
let version = ProtocolVersion::V2025_06_18;
let structured = Tool::new(PUBLIC_TOOL, r#"{"type":"object"}"#)?
.with_output_schema(r#"{"type":"object"}"#)?;
let dispatcher = Dispatcher::new(Arc::new(
Server::builder("structured")
.tool(structured, echo_handler())
.build(),
));
let mut profile = resolve(negotiation(version));
profile.remove(Feature::ToolStructuredOutput);
let (peer, sink) = server_side(version, AuthContext::anonymous());
let ctx = request_context(&peer.with_profile(profile));
let outcome = dispatcher
.dispatch(&ctx, "tools/call", &call_params(PUBLIC_TOOL))
.await;
assert_eq!(
outcome.as_ref().err(),
Some(&DispatchError::UnknownTool),
"a session without ToolStructuredOutput called a tool that needs it"
);
assert_eq!(sink.written(), 0, "the refusal wrote to the transport");
Ok(())
}
#[tokio::test]
async fn a_client_without_the_feature_refuses_before_sending() -> Result<(), TestError> {
let version = ProtocolVersion::V2025_06_18;
let (peer, sink) = server_side(version, AuthContext::anonymous());
let empty = Negotiation {
version,
client_caps: CapabilitySet::empty(),
server_caps: CapabilitySet::empty(),
transport: TransportKind::StreamableHttp,
};
let client = Client::connect(
empty,
Arc::new(InProcess {
dispatcher: Arc::new(Dispatcher::new(Arc::new(server()?))),
ctx: request_context(&peer),
}),
);
if client.supports(Feature::Tools) {
return Err(Failure::from(
"a client with no negotiated tools believes it has them",
));
}
match client.list_tools().await {
Ok(answer) => Err(Failure::from(format!(
"the refusal did not happen: {answer}"
))),
Err(_) if sink.written() == 0 => Ok(()),
Err(error) => Err(Failure::from(format!(
"the client refused, but something had already been written: {error}"
))),
}
}
fn call_params(name: &str) -> JsonObject {
JsonObject::from_value(serde_json::json!({ "name": name })).unwrap_or_default()
}