#![cfg(all(
feature = "streamable-http",
feature = "http-client",
not(target_arch = "wasm32")
))]
mod common;
use common::v2::{
build_v2_server, build_v2_server_with, extensions_capabilities, spawn_default_config,
spawn_shared, BearerSubjects, SearchTool, FRAME_TIMEOUT, V1, V2,
};
use std::net::SocketAddr;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use futures::StreamExt;
use pmcp::client::subscriptions::SubscriptionStream;
use pmcp::server::http_middleware::{
ServerHttpContext, ServerHttpMiddleware, ServerHttpMiddlewareChain, ServerHttpRequest,
};
use pmcp::server::streamable_http_server::StreamableHttpServerConfig;
use pmcp::server::Server;
use pmcp::shared::streamable_http::StreamableHttpTransportConfigBuilder;
use pmcp::shared::StreamableHttpTransport;
use pmcp::types::protocol::error_codes::METHOD_NOT_FOUND;
use pmcp::types::protocol::ProtocolVersion;
use pmcp::types::subscriptions::SubscriptionFilter;
use pmcp::types::{
ClientCapabilities, PromptCapabilities, ResourceCapabilities, ServerCapabilities,
ServerNotification, ToolCapabilities,
};
use pmcp::{Client, ClientBuilder};
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
use url::Url;
const RESOURCE_URI: &str = "mem://greeting";
fn advertising(prompts: bool, resource_subscribe: bool) -> ServerCapabilities {
let mut caps = extensions_capabilities();
caps.tools = Some(ToolCapabilities {
list_changed: Some(true),
});
if prompts {
caps.prompts = Some(PromptCapabilities {
list_changed: Some(true),
});
}
if resource_subscribe {
caps.resources = Some(ResourceCapabilities {
subscribe: Some(true),
list_changed: Some(true),
});
}
caps
}
fn server_with(caps: ServerCapabilities) -> Server {
build_v2_server_with("v2-subscriptions-client", caps)
}
fn authenticated_server() -> Server {
Server::builder()
.name("v2-subscriptions-client-auth")
.version("1.0.0")
.capabilities(advertising(false, false))
.with_supported_protocol_versions([
ProtocolVersion(V1.to_string()),
ProtocolVersion(V2.to_string()),
])
.auth_provider(BearerSubjects)
.tool("search", SearchTool)
.build()
.expect("server builds")
}
async fn spawn(server: Server) -> (SocketAddr, JoinHandle<()>) {
spawn_default_config(server).await
}
#[derive(Debug, Default)]
struct MethodCounts {
subscribe: AtomicUsize,
unsubscribe: AtomicUsize,
total: AtomicUsize,
}
struct CountingMiddleware {
counts: Arc<MethodCounts>,
}
#[async_trait]
impl ServerHttpMiddleware for CountingMiddleware {
async fn on_request(
&self,
request: &mut ServerHttpRequest,
_context: &ServerHttpContext,
) -> pmcp::Result<()> {
self.counts.total.fetch_add(1, Ordering::SeqCst);
let body = String::from_utf8_lossy(&request.body);
if body.contains("\"resources/subscribe\"") {
self.counts.subscribe.fetch_add(1, Ordering::SeqCst);
}
if body.contains("\"resources/unsubscribe\"") {
self.counts.unsubscribe.fetch_add(1, Ordering::SeqCst);
}
Ok(())
}
}
async fn spawn_counting(server: Server) -> (SocketAddr, JoinHandle<()>, Arc<MethodCounts>) {
let counts = Arc::new(MethodCounts::default());
let mut chain = ServerHttpMiddlewareChain::new();
chain.add(Arc::new(CountingMiddleware {
counts: Arc::clone(&counts),
}));
let config = StreamableHttpServerConfig {
http_middleware: Some(Arc::new(chain)),
..StreamableHttpServerConfig::default()
};
let (addr, handle) = common::v2::spawn_with(server, config).await;
(addr, handle, counts)
}
fn transport_for(addr: SocketAddr, bearer: Option<&str>) -> StreamableHttpTransport {
let url = Url::parse(&format!("http://{addr}/")).expect("loopback URL parses");
let mut builder = StreamableHttpTransportConfigBuilder::new(url);
if let Some(bearer) = bearer {
builder = builder.with_header("authorization", format!("Bearer {bearer}"));
}
StreamableHttpTransport::new(builder.build())
}
fn v2_client(addr: SocketAddr, bearer: Option<&str>) -> Client<StreamableHttpTransport> {
ClientBuilder::new(transport_for(addr, bearer))
.with_protocol_version(ProtocolVersion(V2.to_string()))
.expect("2026-07-28 is selectable")
.build()
}
fn v1_client(addr: SocketAddr) -> Client<StreamableHttpTransport> {
ClientBuilder::new(transport_for(addr, None)).build()
}
fn tools_only() -> SubscriptionFilter {
SubscriptionFilter {
tools_list_changed: Some(true),
..SubscriptionFilter::default()
}
}
async fn next_frame(stream: &mut SubscriptionStream) -> Option<pmcp::Result<ServerNotification>> {
tokio::time::timeout(FRAME_TIMEOUT, stream.next())
.await
.expect("a subscriptions/listen frame must arrive within the timeout")
}
async fn expect_no_frame(stream: &mut SubscriptionStream, window: Duration) {
if let Ok(Some(item)) = tokio::time::timeout(window, stream.next()).await {
panic!("an unrequested notification reached the client: {item:?}");
}
}
#[tokio::test]
async fn client_receives_acknowledgement_first() {
let (addr, handle) = spawn(server_with(advertising(true, true))).await;
let client = v2_client(addr, None);
let stream = client
.subscriptions_listen(tools_only())
.await
.expect("an advertising server serves the stream");
assert_eq!(
stream.acknowledged().notifications,
SubscriptionFilter {
tools_list_changed: Some(true),
..SubscriptionFilter::default()
},
"the ack reports the AGREED filter, never a superset of the request"
);
let id = stream.subscription_id().to_string();
assert!(!id.is_empty(), "the stream knows its own subscription id");
drop(stream);
handle.abort();
}
#[tokio::test]
async fn client_receives_tools_list_changed() {
let server = Arc::new(Mutex::new(server_with(advertising(false, false))));
let (addr, handle) = spawn_shared(Arc::clone(&server)).await;
let client = v2_client(addr, None);
let mut stream = client
.subscriptions_listen(tools_only())
.await
.expect("the stream is served");
server
.lock()
.await
.send_notification(ServerNotification::ToolsChanged)
.await;
let notification = next_frame(&mut stream)
.await
.expect("a notification arrives")
.expect("and it decodes");
assert!(
matches!(notification, ServerNotification::ToolsChanged),
"the client receives the tools/list_changed it subscribed to: {notification:?}"
);
drop(stream);
handle.abort();
}
#[tokio::test]
async fn successive_listen_calls_mint_distinct_subscription_ids() {
let server = Arc::new(Mutex::new(authenticated_server()));
let (addr, handle) = spawn_shared(Arc::clone(&server)).await;
let client = v2_client(addr, Some("alice"));
let mut first = client
.subscriptions_listen(tools_only())
.await
.expect("the first stream is served");
let mut second = client.subscriptions_listen(tools_only()).await.expect(
"the second stream is served too: its id is FRESH, so it cannot collide \
with the first — which the server still holds LIVE under the same principal",
);
assert_ne!(
first.subscription_id(),
second.subscription_id(),
"every subscriptions_listen call MUST mint a fresh subscription id; a \
sticky or counter-derived id would collide with a live incumbent on \
reconnect and be refused for the rest of the keep-alive window"
);
server
.lock()
.await
.send_notification(ServerNotification::ToolsChanged)
.await;
for (label, stream) in [("first", &mut first), ("second", &mut second)] {
let notification = next_frame(stream)
.await
.unwrap_or_else(|| panic!("{label}: a notification must arrive"))
.unwrap_or_else(|e| {
panic!("{label}: the frame must be tagged with THIS stream's id: {e}")
});
assert!(
matches!(notification, ServerNotification::ToolsChanged),
"{label}: both streams coexist and both receive the fan-out: {notification:?}"
);
}
drop(first);
drop(second);
handle.abort();
}
#[tokio::test]
async fn client_does_not_receive_unrequested_types() {
let server = Arc::new(Mutex::new(server_with(advertising(true, false))));
let (addr, handle) = spawn_shared(Arc::clone(&server)).await;
let client = v2_client(addr, None);
let mut stream = client
.subscriptions_listen(tools_only())
.await
.expect("the stream is served");
assert_eq!(
stream.acknowledged().notifications.prompts_list_changed,
None,
"an unrequested type is OMITTED from the agreed filter"
);
{
let server = server.lock().await;
server
.send_notification(ServerNotification::PromptsChanged)
.await;
server
.send_notification(ServerNotification::ToolsChanged)
.await;
}
let delivered = next_frame(&mut stream)
.await
.expect("a notification arrives")
.expect("and it decodes");
assert!(
matches!(delivered, ServerNotification::ToolsChanged),
"only the REQUESTED type appears, and it appears FIRST despite prompts \
being triggered first: {delivered:?}"
);
expect_no_frame(&mut stream, Duration::from_millis(300)).await;
drop(stream);
handle.abort();
}
#[tokio::test]
async fn client_stream_drop_releases_server_slot() {
let (addr, handle) = spawn(authenticated_server()).await;
let client = v2_client(addr, Some("capped"));
let mut held: Vec<SubscriptionStream> = Vec::new();
let mut refusal = None;
for _ in 0..16 {
match client.subscriptions_listen(tools_only()).await {
Ok(stream) => held.push(stream),
Err(e) => {
refusal = Some(e);
break;
},
}
}
let refusal = refusal.expect("the per-principal cap must refuse an N+1th stream");
assert!(
refusal.to_string().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;
if let Ok(stream) = client.subscriptions_listen(tools_only()).await {
held.push(stream);
accepted = true;
break;
}
}
assert!(
accepted,
"dropping a SubscriptionStream must release the server's registry entry AND its permit"
);
drop(held);
handle.abort();
}
#[tokio::test]
async fn client_listen_against_non_advertising_server_errors() {
let (addr, handle) = spawn_default_config(build_v2_server()).await;
let client = v2_client(addr, None);
let error = client
.subscriptions_listen(tools_only())
.await
.expect_err("a non-advertising server does not serve the stream");
match error {
pmcp::Error::Protocol { code, .. } => assert_eq!(
code.as_i32(),
METHOD_NOT_FOUND,
"the server's structured -32601 reaches the caller unchanged"
),
other => panic!("expected a structured protocol error, got {other:?}"),
}
handle.abort();
}
#[tokio::test]
async fn client_subscribe_resource_retired_on_v2() {
let (addr, handle, counts) = spawn_counting(server_with(advertising(false, true))).await;
let client = v2_client(addr, None);
for (method, result) in [
(
"resources/subscribe",
client.subscribe_resource(RESOURCE_URI.to_string()).await,
),
(
"resources/unsubscribe",
client.unsubscribe_resource(RESOURCE_URI.to_string()).await,
),
] {
let error = result.expect_err("the RPC is gone from the 2026-07-28 schema");
assert!(error.is_retired_on_v2(), "{method}: {error}");
assert_eq!(error.retired_method(), Some(method));
assert!(
error.to_string().contains("subscriptions/listen"),
"{method}: the error names the replacement: {error}"
);
}
assert_eq!(
counts.subscribe.load(Ordering::SeqCst),
0,
"a v2 resources/subscribe must never reach the server"
);
assert_eq!(
counts.unsubscribe.load(Ordering::SeqCst),
0,
"a v2 resources/unsubscribe must never reach the server"
);
client
.list_tools(None)
.await
.expect("a live v2 request still works");
assert!(
counts.total.load(Ordering::SeqCst) > 0,
"traffic must have reached the server, or the counts above prove nothing"
);
handle.abort();
}
#[tokio::test]
async fn v1_client_subscribe_unchanged() {
let (addr, handle, counts) = spawn_counting(server_with(advertising(false, true))).await;
let mut client = v1_client(addr);
client
.initialize(ClientCapabilities::default())
.await
.expect("the v1 handshake still works");
client
.subscribe_resource(RESOURCE_URI.to_string())
.await
.expect("v1 resources/subscribe is unchanged");
assert_eq!(
counts.subscribe.load(Ordering::SeqCst),
1,
"a v1 subscribe really does travel to the server"
);
handle.abort();
}