#![cfg(all(
feature = "streamable-http",
feature = "http-client",
not(target_arch = "wasm32")
))]
mod common;
#[path = "common/duplex.rs"]
mod duplex;
use common::v2::{
build_v2_server_with, post, spawn_default_config, spawn_shared, v2_body, v2_headers,
BearerSubjects, GreetingPrompt, OptionalBearer, SearchTool, FRAME_TIMEOUT, V1, V2,
};
use pmcp::server::Server;
use pmcp::types::protocol::error_codes::{AUTHENTICATION_REQUIRED, METHOD_NOT_FOUND, RATE_LIMITED};
use pmcp::types::protocol::ProtocolVersion;
use pmcp::types::subscriptions::{
advertises_subscriptions, ACKNOWLEDGED_METHOD, MAX_AGREED_RESOURCE_SUBSCRIPTIONS,
SUBSCRIPTION_ID_META_KEY,
};
use pmcp::types::{
PromptCapabilities, ResourceCapabilities, ResourceUpdatedParams, ServerCapabilities,
ServerNotification, ToolCapabilities,
};
use serde_json::{json, Value};
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpStream;
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
const CAPABILITY_NAMES: [&str; 4] = [
"tools.listChanged",
"prompts.listChanged",
"resources.listChanged",
"resources.subscribe",
];
fn advertising(which: Option<&str>) -> ServerCapabilities {
let mut caps = ServerCapabilities::default();
match which {
Some("tools.listChanged") => {
caps.tools = Some(ToolCapabilities {
list_changed: Some(true),
});
},
Some("prompts.listChanged") => {
caps.prompts = Some(PromptCapabilities {
list_changed: Some(true),
});
},
Some("resources.listChanged") => {
caps.resources = Some(ResourceCapabilities {
subscribe: Some(false),
list_changed: Some(true),
});
},
Some("resources.subscribe") => {
caps.resources = Some(ResourceCapabilities {
subscribe: Some(true),
list_changed: Some(false),
});
},
_ => {},
}
caps
}
fn server_with(caps: ServerCapabilities) -> Server {
build_v2_server_with("v2-subscriptions", caps)
}
fn server_with_two_principals() -> Server {
let mut caps = ServerCapabilities::default();
caps.tools = Some(ToolCapabilities {
list_changed: Some(true),
});
caps.prompts = Some(PromptCapabilities {
list_changed: Some(true),
});
Server::builder()
.name("v2-subscriptions-auth")
.version("1.0.0")
.capabilities(caps)
.with_supported_protocol_versions([
ProtocolVersion(V1.to_string()),
ProtocolVersion(V2.to_string()),
])
.auth_provider(BearerSubjects)
.tool("search", SearchTool)
.prompt("greeting", GreetingPrompt)
.build()
.expect("server builds")
}
fn server_with_optional_auth() -> Server {
Server::builder()
.name("v2-subscriptions-optional-auth")
.version("1.0.0")
.capabilities(advertising(Some("tools.listChanged")))
.with_supported_protocol_versions([
ProtocolVersion(V1.to_string()),
ProtocolVersion(V2.to_string()),
])
.auth_provider(OptionalBearer)
.tool("search", SearchTool)
.prompt("greeting", GreetingPrompt)
.build()
.expect("server builds")
}
async fn spawn(server: Server) -> (SocketAddr, JoinHandle<()>) {
spawn_default_config(server).await
}
fn listen_body(id: Value, filter: &Value) -> String {
let mut params = serde_json::Map::new();
params.insert("notifications".to_string(), filter.clone());
v2_body("subscriptions/listen", id, Value::Object(params))
}
fn listen_headers() -> Vec<(String, String)> {
v2_headers("subscriptions/listen", "")
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum SseEvent {
Data(String),
Comment(String),
}
struct SseStream {
reader: BufReader<TcpStream>,
status: u16,
headers: Vec<(String, String)>,
buffer: String,
chunked: bool,
remaining: usize,
finished: bool,
}
impl SseStream {
async fn open(addr: SocketAddr, extra: &[(String, String)], body: &str) -> Self {
let stream = TcpStream::connect(addr).await.expect("connects");
let mut request = format!(
"POST / HTTP/1.1\r\nHost: {addr}\r\nContent-Type: application/json\r\n\
Accept: application/json, text/event-stream\r\nContent-Length: {}\r\n",
body.len()
);
for (name, value) in extra {
request.push_str(&format!("{name}: {value}\r\n"));
}
request.push_str("\r\n");
request.push_str(body);
let mut reader = BufReader::new(stream);
reader
.get_mut()
.write_all(request.as_bytes())
.await
.expect("request written");
let mut status_line = String::new();
reader
.read_line(&mut status_line)
.await
.expect("status line");
let status = status_line
.split_whitespace()
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let mut headers = Vec::new();
loop {
let mut line = String::new();
reader.read_line(&mut line).await.expect("header line");
let line = line.trim_end();
if line.is_empty() {
break;
}
if let Some((name, value)) = line.split_once(':') {
headers.push((name.trim().to_ascii_lowercase(), value.trim().to_string()));
}
}
let chunked = headers
.iter()
.any(|(n, v)| n == "transfer-encoding" && v.contains("chunked"));
let remaining = headers
.iter()
.find(|(n, _)| n == "content-length")
.and_then(|(_, v)| v.parse().ok())
.unwrap_or(0);
Self {
reader,
status,
headers,
buffer: String::new(),
chunked,
remaining,
finished: false,
}
}
fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(n, _)| n == name)
.map(|(_, v)| v.as_str())
}
async fn pull(&mut self) -> bool {
if self.finished {
return false;
}
if !self.chunked {
let mut payload = vec![0u8; self.remaining];
let ok = self.remaining > 0 && self.reader.read_exact(&mut payload).await.is_ok();
self.finished = true;
if !ok {
return false;
}
self.buffer.push_str(&String::from_utf8_lossy(&payload));
return true;
}
let mut size_line = String::new();
if self.reader.read_line(&mut size_line).await.unwrap_or(0) == 0 {
self.finished = true;
return false;
}
let size_token = size_line.trim().split(';').next().unwrap_or("").to_string();
let Ok(size) = usize::from_str_radix(&size_token, 16) else {
self.finished = true;
return false;
};
if size == 0 {
self.finished = true;
return false;
}
let mut payload = vec![0u8; size];
if self.reader.read_exact(&mut payload).await.is_err() {
self.finished = true;
return false;
}
let mut crlf = [0u8; 2];
let _ = self.reader.read_exact(&mut crlf).await;
self.buffer.push_str(&String::from_utf8_lossy(&payload));
true
}
fn take_block(&mut self) -> Option<String> {
let end = self.buffer.find("\n\n")?;
let block = self.buffer[..end].to_string();
self.buffer.drain(..end + 2);
Some(block)
}
async fn next_event(&mut self) -> Option<SseEvent> {
loop {
if let Some(block) = self.take_block() {
let mut data = String::new();
let mut comment = None;
for line in block.lines() {
if let Some(rest) = line.strip_prefix("data:") {
data.push_str(rest.trim_start());
} else if let Some(rest) = line.strip_prefix(':') {
comment = Some(rest.trim().to_string());
}
}
if !data.is_empty() {
return Some(SseEvent::Data(data));
}
if let Some(comment) = comment {
return Some(SseEvent::Comment(comment));
}
continue;
}
if !self.pull().await {
if !self.buffer.trim().is_empty() {
let rest = std::mem::take(&mut self.buffer);
return Some(SseEvent::Data(rest.trim().to_string()));
}
return None;
}
}
}
async fn expect_json(&mut self) -> Value {
loop {
let event = tokio::time::timeout(FRAME_TIMEOUT, self.next_event())
.await
.expect("a frame arrived within the timeout")
.expect("the stream did not end");
if let SseEvent::Data(data) = event {
return serde_json::from_str(&data).expect("the frame is JSON");
}
}
}
async fn expect_no_json(&mut self, window: Duration) {
if let Ok(Some(SseEvent::Data(data))) =
tokio::time::timeout(window, self.next_event()).await
{
panic!("unexpected frame delivered to this stream: {data}");
}
}
}
fn subscription_id_of(frame: &Value) -> Option<&Value> {
["params", "result"].into_iter().find_map(|section| {
frame
.get(section)?
.get("_meta")?
.get(SUBSCRIPTION_ID_META_KEY)
})
}
#[tokio::test]
async fn absent_capability_is_conformant() {
let (addr, handle) = spawn(server_with(advertising(None))).await;
let discover = post(
addr,
&v2_headers("server/discover", ""),
&v2_body("server/discover", json!(1), json!({})),
)
.await;
assert_eq!(discover.status, 200, "discover must be OBSERVED");
let capabilities: ServerCapabilities =
serde_json::from_value(discover.body["result"]["capabilities"].clone())
.expect("the projection deserializes");
assert!(
!advertises_subscriptions(&capabilities),
"the default advertises no subscription-delivered capability: {:?}",
discover.body["result"]["capabilities"]
);
let listen = post(addr, &listen_headers(), &listen_body(json!(2), &json!({}))).await;
assert_eq!(listen.status, 404, "spec: unimplemented method is 404");
assert_eq!(listen.body["error"]["code"], json!(METHOD_NOT_FOUND));
assert_eq!(listen.body["id"], json!(2), "the ORIGINAL id is echoed");
handle.abort();
}
#[tokio::test]
async fn advertise_implies_serve() {
for which in CAPABILITY_NAMES {
let (addr, handle) = spawn(server_with(advertising(Some(which)))).await;
let mut stream = SseStream::open(
addr,
&listen_headers(),
&listen_body(json!(1), &json!({ "toolsListChanged": true })),
)
.await;
assert_eq!(
stream.status, 200,
"{which} is advertised, so the stream must be served"
);
assert_eq!(
stream.header("content-type"),
Some("text/event-stream"),
"{which}: the served response is an SSE stream"
);
let first = stream.expect_json().await;
assert_ne!(
first["error"]["code"],
json!(METHOD_NOT_FOUND),
"{which} is advertised, so -32601 here would be a conformance FAILURE"
);
assert_eq!(
first["method"],
json!(ACKNOWLEDGED_METHOD),
"{which}: the first frame is the acknowledgement"
);
drop(stream);
handle.abort();
}
}
#[tokio::test]
async fn listen_stream_protocol() {
let server = Arc::new(Mutex::new(server_with(advertising(Some(
"tools.listChanged",
)))));
let (addr, handle) = spawn_shared(Arc::clone(&server)).await;
let mut stream = SseStream::open(
addr,
&listen_headers(),
&listen_body(json!(11), &json!({ "toolsListChanged": true })),
)
.await;
assert_eq!(stream.status, 200);
assert_eq!(stream.header("content-type"), Some("text/event-stream"));
assert_eq!(
stream.header("x-accel-buffering"),
Some("no"),
"spec: servers SHOULD disable proxy buffering on the stream"
);
let ack = stream.expect_json().await;
assert_eq!(ack["method"], json!(ACKNOWLEDGED_METHOD));
assert_eq!(
ack["params"]["notifications"],
json!({ "toolsListChanged": true }),
"the ack reports the AGREED filter"
);
assert_eq!(
subscription_id_of(&ack),
Some(&json!(11)),
"the subscriptionId equals the listen request's JSON-RPC id"
);
server
.lock()
.await
.send_notification(ServerNotification::ToolsChanged)
.await;
let notification = stream.expect_json().await;
assert_eq!(
notification["method"],
json!("notifications/tools/list_changed")
);
assert_eq!(
subscription_id_of(¬ification),
Some(&json!(11)),
"every subsequent frame carries the SAME subscriptionId"
);
drop(stream);
handle.abort();
}
#[tokio::test]
async fn no_unrequested_notification_types() {
let mut caps = ServerCapabilities::default();
caps.tools = Some(ToolCapabilities {
list_changed: Some(true),
});
caps.prompts = Some(PromptCapabilities {
list_changed: Some(true),
});
let server = Arc::new(Mutex::new(server_with(caps)));
let (addr, handle) = spawn_shared(Arc::clone(&server)).await;
let mut stream = SseStream::open(
addr,
&listen_headers(),
&listen_body(json!(21), &json!({ "toolsListChanged": true })),
)
.await;
let ack = stream.expect_json().await;
assert_eq!(
ack["params"]["notifications"],
json!({ "toolsListChanged": true }),
"the agreed filter is never a superset of the request"
);
{
let server = server.lock().await;
server
.send_notification(ServerNotification::PromptsChanged)
.await;
server
.send_notification(ServerNotification::ToolsChanged)
.await;
}
let delivered = stream.expect_json().await;
assert_eq!(
delivered["method"],
json!("notifications/tools/list_changed"),
"only the REQUESTED type appears, and it appears FIRST despite prompts \
being triggered first"
);
stream.expect_no_json(Duration::from_millis(300)).await;
drop(stream);
handle.abort();
}
#[tokio::test]
async fn ack_is_first_frame() {
let server = Arc::new(Mutex::new(server_with(advertising(Some(
"tools.listChanged",
)))));
let (addr, handle) = spawn_shared(Arc::clone(&server)).await;
let mut stream = SseStream::open(
addr,
&listen_headers(),
&listen_body(json!(31), &json!({ "toolsListChanged": true })),
)
.await;
for _ in 0..5 {
server
.lock()
.await
.send_notification(ServerNotification::ToolsChanged)
.await;
}
let first = stream.expect_json().await;
assert_eq!(
first["method"],
json!(ACKNOWLEDGED_METHOD),
"the acknowledgement MUST be the first message on the stream"
);
drop(stream);
handle.abort();
}
const SUBSCRIBED_URI: &str = "mem://a";
const UNSUBSCRIBED_URI: &str = "mem://b";
#[tokio::test]
async fn resource_subscriptions_deliver_the_subscribed_uri_and_not_another() {
let server = Arc::new(Mutex::new(server_with(advertising(Some(
"resources.subscribe",
)))));
let (addr, handle) = spawn_shared(Arc::clone(&server)).await;
let mut stream = SseStream::open(
addr,
&listen_headers(),
&listen_body(
json!(51),
&json!({ "resourceSubscriptions": [SUBSCRIBED_URI] }),
),
)
.await;
assert_eq!(stream.status, 200, "resources.subscribe is advertised");
assert_eq!(
stream.header("content-type"),
Some("text/event-stream"),
"the served response is a stream, not a refusal body"
);
let ack = stream.expect_json().await;
assert_eq!(ack["method"], json!(ACKNOWLEDGED_METHOD));
assert_eq!(
ack["params"]["notifications"],
json!({ "resourceSubscriptions": [SUBSCRIBED_URI] }),
"the agreed filter echoes the requested URI list EXACTLY — the whole \
object is compared, so an extra agreed field would fail here too: {ack}"
);
assert_eq!(
subscription_id_of(&ack),
Some(&json!(51)),
"the subscriptionId equals the listen request's JSON-RPC id: {ack}"
);
{
let server = server.lock().await;
server
.send_notification(ServerNotification::ResourceUpdated(
ResourceUpdatedParams::new(UNSUBSCRIBED_URI),
))
.await;
server
.send_notification(ServerNotification::ResourceUpdated(
ResourceUpdatedParams::new(SUBSCRIBED_URI),
))
.await;
}
let delivered = stream.expect_json().await;
assert_eq!(
delivered["method"],
json!("notifications/resources/updated"),
"the subscribed URI is delivered as a resources/updated frame: {delivered}"
);
assert_eq!(
delivered["params"]["uri"],
json!(SUBSCRIBED_URI),
"and it is the SUBSCRIBED URI that arrives first, despite \
{UNSUBSCRIBED_URI} having been fired before it: {delivered}"
);
assert_eq!(
subscription_id_of(&delivered),
Some(&json!(51)),
"a delivered resources/updated carries the stream's subscriptionId: {delivered}"
);
stream.expect_no_json(Duration::from_millis(300)).await;
drop(stream);
handle.abort();
let _ = handle.await;
}
#[tokio::test]
async fn a_resource_subscriptions_stream_is_not_a_resources_list_changed_stream() {
let server = Arc::new(Mutex::new(server_with(advertising(Some(
"resources.subscribe",
)))));
let (addr, handle) = spawn_shared(Arc::clone(&server)).await;
let mut stream = SseStream::open(
addr,
&listen_headers(),
&listen_body(
json!(52),
&json!({
"resourceSubscriptions": [SUBSCRIBED_URI],
"resourcesListChanged": true,
}),
),
)
.await;
let ack = stream.expect_json().await;
let agreed = &ack["params"]["notifications"];
assert!(
agreed.get("resourcesListChanged").is_none(),
"an unsupported requested type is OMITTED from the agreed filter, not \
agreed as `false` and not emitted as `null`: {ack}"
);
assert_eq!(
*agreed,
json!({ "resourceSubscriptions": [SUBSCRIBED_URI] }),
"only the supported half survives the intersection: {ack}"
);
{
let server = server.lock().await;
server
.send_notification(ServerNotification::ResourcesChanged)
.await;
server
.send_notification(ServerNotification::ResourceUpdated(
ResourceUpdatedParams::new(SUBSCRIBED_URI),
))
.await;
}
let delivered = stream.expect_json().await;
assert_eq!(
delivered["method"],
json!("notifications/resources/updated"),
"the FIRST frame is the resources/updated, even though \
resources/list_changed was fired before it — the list-changed half was \
never agreed to, so it is not merely late: {delivered}"
);
stream.expect_no_json(Duration::from_millis(300)).await;
drop(stream);
handle.abort();
let _ = handle.await;
}
#[tokio::test]
async fn resources_list_changed_is_agreed_and_delivered_when_subscriptions_are_not() {
let server = Arc::new(Mutex::new(server_with(advertising(Some(
"resources.listChanged",
)))));
let (addr, handle) = spawn_shared(Arc::clone(&server)).await;
let mut stream = SseStream::open(
addr,
&listen_headers(),
&listen_body(
json!(53),
&json!({
"resourcesListChanged": true,
"resourceSubscriptions": [SUBSCRIBED_URI],
}),
),
)
.await;
assert_eq!(stream.status, 200, "resources.listChanged is advertised");
let ack = stream.expect_json().await;
let agreed = &ack["params"]["notifications"];
assert!(
agreed.get("resourceSubscriptions").is_none(),
"`resources.subscribe` is NOT advertised, so the requested URI list is \
OMITTED from the agreed filter — the key must be ABSENT, not present as \
`[]` and not present as `null`: {ack}"
);
assert_eq!(
*agreed,
json!({ "resourcesListChanged": true }),
"only the advertised half survives: {ack}"
);
assert_eq!(subscription_id_of(&ack), Some(&json!(53)));
{
let server = server.lock().await;
server
.send_notification(ServerNotification::ResourceUpdated(
ResourceUpdatedParams::new(SUBSCRIBED_URI),
))
.await;
server
.send_notification(ServerNotification::ResourcesChanged)
.await;
}
let delivered = stream.expect_json().await;
assert_eq!(
delivered["method"],
json!("notifications/resources/list_changed"),
"the agreed half is delivered, and it arrives FIRST despite the \
un-agreed resources/updated having been fired before it: {delivered}"
);
assert_eq!(
subscription_id_of(&delivered),
Some(&json!(53)),
"a delivered resources/list_changed carries the stream's \
subscriptionId: {delivered}"
);
stream.expect_no_json(Duration::from_millis(300)).await;
drop(stream);
handle.abort();
let _ = handle.await;
}
#[tokio::test]
async fn an_over_bound_resource_subscriptions_list_is_truncated_and_reported() {
let uris: Vec<String> = (0..=MAX_AGREED_RESOURCE_SUBSCRIPTIONS)
.map(|index| format!("mem://r/{index}"))
.collect();
let kept = uris[0].clone();
let truncated_away = uris[MAX_AGREED_RESOURCE_SUBSCRIPTIONS].clone();
let server = Arc::new(Mutex::new(server_with(advertising(Some(
"resources.subscribe",
)))));
let (addr, handle) = spawn_shared(Arc::clone(&server)).await;
let mut stream = SseStream::open(
addr,
&listen_headers(),
&listen_body(json!(54), &json!({ "resourceSubscriptions": uris })),
)
.await;
assert_eq!(
stream.status, 200,
"an over-bound list is TRUNCATED, not rejected: truncating keeps the \
operation conformant because the agreed set is allowed to omit entries"
);
assert_eq!(
stream.header("content-type"),
Some("text/event-stream"),
"the stream is served, not refused"
);
let ack = stream.expect_json().await;
let agreed = ack["params"]["notifications"]["resourceSubscriptions"]
.as_array()
.expect("the agreed filter reports the URI list it kept");
assert_eq!(
agreed.len(),
MAX_AGREED_RESOURCE_SUBSCRIPTIONS,
"{} URIs were requested; the acknowledgement reports exactly \
MAX_AGREED_RESOURCE_SUBSCRIPTIONS of them",
uris.len()
);
assert_eq!(
agreed[0],
json!(kept),
"truncation keeps the HEAD of the requested list (`.take(..)`), so index \
0 survives"
);
assert!(
!agreed.contains(&json!(truncated_away)),
"the URI at index MAX_AGREED_RESOURCE_SUBSCRIPTIONS is past the bound and \
must not appear in the agreed list"
);
assert_eq!(subscription_id_of(&ack), Some(&json!(54)));
{
let server = server.lock().await;
server
.send_notification(ServerNotification::ResourceUpdated(
ResourceUpdatedParams::new(truncated_away.clone()),
))
.await;
server
.send_notification(ServerNotification::ResourceUpdated(
ResourceUpdatedParams::new(kept.clone()),
))
.await;
}
let delivered = stream.expect_json().await;
assert_eq!(
delivered["method"],
json!("notifications/resources/updated"),
"the kept URI still delivers after truncation: {delivered}"
);
assert_eq!(
delivered["params"]["uri"],
json!(kept),
"a URI that survived truncation delivers; the one truncated away does \
not, even though it was fired first: {delivered}"
);
stream.expect_no_json(Duration::from_millis(300)).await;
drop(stream);
handle.abort();
let _ = handle.await;
}
#[tokio::test]
async fn two_callers_same_request_id_do_not_cross() {
let server = Arc::new(Mutex::new(server_with_two_principals()));
let (addr, handle) = spawn_shared(Arc::clone(&server)).await;
let mut alice_headers = listen_headers();
alice_headers.push(("authorization".to_string(), "Bearer alice".to_string()));
let mut bob_headers = listen_headers();
bob_headers.push(("authorization".to_string(), "Bearer bob".to_string()));
let mut alice = SseStream::open(
addr,
&alice_headers,
&listen_body(json!(1), &json!({ "toolsListChanged": true })),
)
.await;
let mut bob = SseStream::open(
addr,
&bob_headers,
&listen_body(json!(1), &json!({ "promptsListChanged": true })),
)
.await;
for stream in [&mut alice, &mut bob] {
let ack = stream.expect_json().await;
assert_eq!(ack["method"], json!(ACKNOWLEDGED_METHOD));
assert_eq!(subscription_id_of(&ack), Some(&json!(1)));
}
server
.lock()
.await
.send_notification(ServerNotification::ToolsChanged)
.await;
let delivered = alice.expect_json().await;
assert_eq!(
delivered["method"],
json!("notifications/tools/list_changed"),
"alice's entry survived bob's registration under the SAME request id"
);
bob.expect_no_json(Duration::from_millis(300)).await;
drop(alice);
drop(bob);
handle.abort();
}
#[tokio::test]
async fn same_principal_id_reuse_rejects_the_second_and_spares_the_first() {
let server = Arc::new(Mutex::new(server_with_two_principals()));
let (addr, handle) = spawn_shared(Arc::clone(&server)).await;
let mut first_headers = listen_headers();
first_headers.push(("authorization".to_string(), "Bearer alice".to_string()));
let mut second_headers = listen_headers();
second_headers.push(("authorization".to_string(), "Bearer alice".to_string()));
let mut first = SseStream::open(
addr,
&first_headers,
&listen_body(json!(1), &json!({ "toolsListChanged": true })),
)
.await;
let ack = first.expect_json().await;
assert_eq!(
ack["method"],
json!(ACKNOWLEDGED_METHOD),
"the first stream is served, ack first"
);
assert_eq!(subscription_id_of(&ack), Some(&json!(1)));
let mut second = SseStream::open(
addr,
&second_headers,
&listen_body(json!(1), &json!({ "toolsListChanged": true })),
)
.await;
assert_eq!(
second.status, 200,
"a duplicate is a transient, RETRYABLE condition: RATE_LIMITED is not in \
v2_status_for_code's 400 arm, so it answers at 200 with a JSON-RPC error \
body, exactly as both capacity refusals already do"
);
let refusal = second.expect_json().await;
assert!(
refusal["error"].is_object(),
"the second stream is refused, not served: {refusal}"
);
assert_eq!(
refusal["error"]["code"],
json!(RATE_LIMITED),
"the refusal is the RETRYABLE -32005, not the non-retryable -32600 it \
answered with before 113-18: {refusal}"
);
let message = refusal["error"]["message"].as_str().unwrap_or_default();
assert!(
message.contains("already open for this subscription id"),
"the refusal names the real reason: {refusal}"
);
assert!(
!message.contains("too many concurrent"),
"this is a DUPLICATE refusal, not a cap refusal: {refusal}"
);
server
.lock()
.await
.send_notification(ServerNotification::ToolsChanged)
.await;
let delivered = first.expect_json().await;
assert_eq!(
delivered["method"],
json!("notifications/tools/list_changed"),
"the FIRST stream survived the duplicate registration"
);
assert_eq!(
subscription_id_of(&delivered),
Some(&json!(1)),
"and is still tagged with its own subscriptionId"
);
drop(first);
drop(second);
handle.abort();
}
#[tokio::test]
async fn disconnect_releases_registry_slot() {
let (addr, handle) = spawn(server_with_two_principals()).await;
let mut headers = listen_headers();
headers.push(("authorization".to_string(), "Bearer capped".to_string()));
let mut held = Vec::new();
let mut refusal = None;
for id in 0..16 {
let mut stream = SseStream::open(
addr,
&headers,
&listen_body(json!(id), &json!({ "toolsListChanged": true })),
)
.await;
let first = stream.expect_json().await;
if first["error"].is_object() {
refusal = Some(first);
break;
}
held.push(stream);
}
let refusal = refusal.expect("the per-principal cap must refuse an N+1th stream");
assert!(
refusal["error"]["message"]
.as_str()
.unwrap_or_default()
.contains("too many concurrent"),
"the refusal names the concurrency bound: {refusal}"
);
assert!(!held.is_empty(), "some streams were accepted first");
drop(held.pop().expect("at least one open stream"));
let mut accepted = false;
for _ in 0..40 {
tokio::time::sleep(Duration::from_millis(50)).await;
let mut probe = SseStream::open(
addr,
&headers,
&listen_body(json!(99), &json!({ "toolsListChanged": true })),
)
.await;
let first = probe.expect_json().await;
if first["error"].is_object() {
drop(probe);
continue;
}
assert_eq!(first["method"], json!(ACKNOWLEDGED_METHOD));
held.push(probe);
accepted = true;
break;
}
assert!(
accepted,
"a disconnect must release the registry entry AND the permit"
);
drop(held);
handle.abort();
}
#[tokio::test]
async fn unauthenticated_listen_is_refused_on_an_auth_configured_server() {
let (addr, handle) = spawn(server_with_optional_auth()).await;
let mut stream = SseStream::open(
addr,
&listen_headers(),
&listen_body(json!(41), &json!({ "toolsListChanged": true })),
)
.await;
assert_eq!(
stream.status, 200,
"-32003 is DELIBERATELY unremapped: it is not in v2_status_for_code's \
400 arm, so it answers at HTTP 200 with a JSON-RPC error body exactly \
like the three RATE_LIMITED listen refusals. Remapping it to 401 would \
change the status of every other emitter of that code on this transport"
);
assert_ne!(
stream.header("content-type"),
Some("text/event-stream"),
"no stream body is opened for a refused caller"
);
let refusal = stream.expect_json().await;
assert_eq!(
refusal["error"]["code"],
json!(AUTHENTICATION_REQUIRED),
"the refusal is -32003, the same fail-closed answer the MRTR ingress \
gives on this server: {refusal}"
);
assert_eq!(
refusal["id"],
json!(41),
"the ORIGINAL request id is echoed: {refusal}"
);
assert!(
refusal["result"].is_null(),
"a refusal carries no result: {refusal}"
);
drop(stream);
handle.abort();
}
#[tokio::test]
async fn unauthenticated_listen_still_serves_on_a_server_with_no_auth_provider() {
let (addr, handle) = spawn(server_with(advertising(Some("tools.listChanged")))).await;
let mut stream = SseStream::open(
addr,
&listen_headers(),
&listen_body(json!(42), &json!({ "toolsListChanged": true })),
)
.await;
assert_eq!(
stream.status, 200,
"a no-auth server still serves the stream"
);
assert_eq!(
stream.header("content-type"),
Some("text/event-stream"),
"and it really is a stream, not a refusal body"
);
let ack = stream.expect_json().await;
assert_eq!(
ack["method"],
json!(ACKNOWLEDGED_METHOD),
"the acknowledgement arrives exactly as before the D-113-N fix: {ack}"
);
assert_eq!(subscription_id_of(&ack), Some(&json!(42)));
let mut held = vec![stream];
for id in 43..47 {
let mut extra = SseStream::open(
addr,
&listen_headers(),
&listen_body(json!(id), &json!({ "toolsListChanged": true })),
)
.await;
let frame = extra.expect_json().await;
assert_eq!(
frame["method"],
json!(ACKNOWLEDGED_METHOD),
"anonymous stream {id} must be served: a per-stream principal means \
MAX_LISTEN_STREAMS_PER_PRINCIPAL does not bind here: {frame}"
);
held.push(extra);
}
drop(held);
handle.abort();
let _ = handle.await;
}
#[tokio::test]
async fn one_unauthenticated_caller_cannot_exhaust_the_global_listen_budget() {
const ATTEMPTS: i64 = 68;
let (addr, handle) = spawn(server_with_optional_auth()).await;
let mut held = Vec::new();
for id in 0..ATTEMPTS {
let mut stream = SseStream::open(
addr,
&listen_headers(),
&listen_body(json!(id), &json!({ "toolsListChanged": true })),
)
.await;
let frame = stream.expect_json().await;
assert_eq!(
frame["error"]["code"],
json!(AUTHENTICATION_REQUIRED),
"unauthenticated attempt {id} must be REFUSED, never granted a \
private uncapped anon#N principal: {frame}"
);
held.push(stream);
}
let mut authenticated_headers = listen_headers();
authenticated_headers.push(("authorization".to_string(), "Bearer carol".to_string()));
let mut authenticated = SseStream::open(
addr,
&authenticated_headers,
&listen_body(json!(1), &json!({ "toolsListChanged": true })),
)
.await;
assert_eq!(authenticated.status, 200);
assert_eq!(
authenticated.header("content-type"),
Some("text/event-stream"),
"the authenticated subscriber gets a real stream, not a refusal body"
);
let ack = authenticated.expect_json().await;
assert_eq!(
ack["method"],
json!(ACKNOWLEDGED_METHOD),
"an authenticated subscriber still registers after {ATTEMPTS} \
unauthenticated attempts — the global budget was never consumed: {ack}"
);
assert_eq!(subscription_id_of(&ack), Some(&json!(1)));
drop(authenticated);
drop(held);
handle.abort();
let _ = handle.await;
}
#[tokio::test]
async fn subscription_id_is_emitted_on_all_three_listen_frame_classes() {
let server = Arc::new(Mutex::new(server_with(advertising(Some(
"tools.listChanged",
)))));
let (addr, handle) = spawn_shared(Arc::clone(&server)).await;
let mut stream = SseStream::open(
addr,
&listen_headers(),
&listen_body(json!(77), &json!({ "toolsListChanged": true })),
)
.await;
let ack = stream.expect_json().await;
assert_eq!(ack["method"], json!(ACKNOWLEDGED_METHOD));
assert_eq!(
subscription_id_of(&ack),
Some(&json!(77)),
"class (a) acknowledgement: params._meta carries the request's own id: {ack}"
);
server
.lock()
.await
.send_notification(ServerNotification::ToolsChanged)
.await;
let delivered = stream.expect_json().await;
assert_eq!(
delivered["method"],
json!("notifications/tools/list_changed")
);
assert_eq!(
subscription_id_of(&delivered),
Some(&json!(77)),
"class (b) delivered notification: params._meta carries the SAME id: {delivered}"
);
server.lock().await.close_subscription_streams();
let terminal = stream.expect_json().await;
assert!(
terminal["result"].is_object(),
"class (c) is a RESULT, not a notification: {terminal}"
);
assert_eq!(
terminal["id"],
json!(77),
"the terminal result answers the original listen request: {terminal}"
);
assert_eq!(
terminal["result"]["_meta"][SUBSCRIPTION_ID_META_KEY],
json!(77),
"class (c) teardown result: _meta is REQUIRED and carries the id: {terminal}"
);
assert_eq!(subscription_id_of(&terminal), Some(&json!(77)));
drop(stream);
handle.abort();
let _ = handle.await;
}
struct ProgressTool;
#[async_trait::async_trait]
impl pmcp::ToolHandler for ProgressTool {
async fn handle(&self, _args: Value, extra: pmcp::RequestHandlerExtra) -> pmcp::Result<Value> {
extra
.report_progress(1.0, Some(2.0), Some("halfway".to_string()))
.await?;
Ok(json!({ "answer": "ok" }))
}
}
async fn off_stream_notification_frame() -> Value {
use duplex::DuplexTransport;
use pmcp::shared::transport::serialize_message;
use pmcp::shared::{Transport, TransportMessage};
use pmcp::types::notifications::ProgressToken;
use pmcp::types::tools::CallToolRequest;
use pmcp::types::{
ClientCapabilities, ClientNotification, ClientRequest, Implementation, InitializeRequest,
Notification, Request, RequestId, RequestMeta,
};
let server = Server::builder()
.name("v2-subscriptions-off-stream")
.version("1.0.0")
.capabilities(advertising(Some("tools.listChanged")))
.tool("progress", ProgressTool)
.build()
.expect("server builds");
let (mut client, server_transport) = DuplexTransport::pair();
tokio::spawn(async move {
let _ = server.run(server_transport).await;
});
client
.send(TransportMessage::Request {
id: RequestId::from(1i64),
request: Request::Client(Box::new(ClientRequest::Initialize(InitializeRequest::new(
Implementation::new("off-stream-probe", "1.0.0"),
ClientCapabilities::default(),
)))),
})
.await
.expect("initialize sent");
let _initialize_result = receive_bounded(&mut client).await;
client
.send(TransportMessage::Notification(Notification::Client(
ClientNotification::Initialized,
)))
.await
.expect("initialized sent");
let mut call = CallToolRequest::new("progress", json!({}));
call._meta = Some(
RequestMeta::new().with_progress_token(ProgressToken::String("off-stream".to_string())),
);
client
.send(TransportMessage::Request {
id: RequestId::from(2i64),
request: Request::Client(Box::new(ClientRequest::CallTool(call))),
})
.await
.expect("tools/call sent");
loop {
let message = receive_bounded(&mut client).await;
if matches!(message, TransportMessage::Notification(_)) {
let bytes = serialize_message(&message).expect("the frame serializes");
return serde_json::from_slice(&bytes).expect("the frame is JSON");
}
}
}
async fn receive_bounded(client: &mut duplex::DuplexTransport) -> pmcp::shared::TransportMessage {
use pmcp::shared::Transport;
tokio::time::timeout(FRAME_TIMEOUT, client.receive())
.await
.expect("a frame arrived within the timeout")
.expect("the duplex peer is alive")
}
#[tokio::test]
async fn a_notification_not_delivered_over_a_listen_stream_carries_no_subscription_id() {
let frame = off_stream_notification_frame().await;
assert_eq!(
frame["method"],
json!("notifications/progress"),
"the probe observed the request-scoped notification it drove: {frame}"
);
assert_eq!(
subscription_id_of(&frame),
None,
"a notification with no subscription must carry NO subscriptionId — the \
key is OPTIONAL on NotificationMetaObject, and pmcp writes it in exactly \
one place (the listen registry's fan-out): {frame}"
);
assert!(
!frame.to_string().contains(SUBSCRIPTION_ID_META_KEY),
"the key must not appear ANYWHERE in the off-stream frame, not merely \
outside `params._meta`: {frame}"
);
}
#[tokio::test]
async fn v2_resources_subscribe_gone() {
let (addr, handle) = spawn(server_with(advertising(Some("resources.subscribe")))).await;
for method in ["resources/subscribe", "resources/unsubscribe"] {
let response = post(
addr,
&v2_headers(method, ""),
&v2_body(method, json!(1), json!({ "uri": "mem://greeting" })),
)
.await;
assert_eq!(response.status, 404, "{method} is retired on v2");
assert_eq!(
response.body["error"]["code"],
json!(METHOD_NOT_FOUND),
"{method}: -32601"
);
assert_eq!(
response.body["id"],
json!(1),
"{method}: original id echoed"
);
}
handle.abort();
}
#[cfg(feature = "v1-compat")]
#[tokio::test]
async fn v1_subscribe_unchanged() {
use common::v2::v1_body;
let (addr, handle) = spawn(server_with(advertising(Some("resources.subscribe")))).await;
let init = post(
addr,
&[],
&v1_body(
"initialize",
json!(1),
json!({
"protocolVersion": V1,
"capabilities": {},
"clientInfo": { "name": "v1-client", "version": "0.0.0" },
}),
),
)
.await;
assert_eq!(init.status, 200, "the v1 handshake still works");
let session = init
.mcp_session_id
.clone()
.expect("v1 mints a session id (HTTP-01 leaves v1 untouched)");
let subscribe = post(
addr,
&[
("mcp-session-id".to_string(), session.clone()),
("mcp-protocol-version".to_string(), V1.to_string()),
],
&v1_body(
"resources/subscribe",
json!(2),
json!({ "uri": "mem://greeting" }),
),
)
.await;
assert_eq!(subscribe.status, 200, "v1 subscribe is untouched");
assert!(
subscribe.body["error"].is_null(),
"v1 subscribe must not be retired: {}",
subscribe.body
);
handle.abort();
}