Skip to content

Commit 85b81da

Browse files
committed
Merge remote-tracking branch 'upstream/main'
Brings in upstream rmcp v1.6.0: - fix(http): fall back to :authority for HTTP/2 (modelcontextprotocol#827) - fix: add init_timeout for streamable-http sessions (modelcontextprotocol#811) - feat(http): log Host/Origin rejections (modelcontextprotocol#826) Conflict resolution: - crates/rmcp/CHANGELOG.md: kept fork's bare-boolean Unreleased entry, inserted upstream's 1.6.0 release section beneath it - crates/rmcp/src/transport/streamable_http_server/tower.rs: auto-merged upstream's Host/Origin/HTTP-2 logging additions; kept fork's tracing::debug! for "Resume failed" (ab4ccdb) over upstream's tracing::warn! revert - Cargo.toml workspace bumped to 1.6.0; fork's newer dep versions (pastey 0.2.2, schemars 1.2, reqwest 0.13.3, url 2.5, process-wrap 9.1, chrono 0.4.44) preserved; rand stays removed (ed5868d) since fork doesn't use it - docs.rs anthropic-ext feature retained
2 parents 6e8aead + 014fb2e commit 85b81da

7 files changed

Lines changed: 260 additions & 24 deletions

File tree

Cargo.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@ default-members = ["crates/rmcp", "crates/rmcp-macros"]
44
resolver = "2"
55

66
[workspace.dependencies]
7-
rmcp = { version = "1.5.0", path = "./crates/rmcp" }
8-
rmcp-macros = { version = "1.5.0", path = "./crates/rmcp-macros" }
7+
rmcp = { version = "1.6.0", path = "./crates/rmcp" }
8+
rmcp-macros = { version = "1.6.0", path = "./crates/rmcp-macros" }
99

1010
[workspace.package]
1111
edition = "2024"
12-
version = "1.5.0"
12+
version = "1.6.0"
1313
authors = ["4t145 <u4t145@163.com>"]
1414
license = "Apache-2.0"
1515
repository = "https://github.com/modelcontextprotocol/rust-sdk/"

crates/rmcp-macros/CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [1.6.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v1.5.0...rmcp-macros-v1.6.0) - 2026-05-01
11+
12+
### Fixed
13+
14+
- *(docs)* use correct Parameters<T> syntax in tool examples ([#814](https://github.com/modelcontextprotocol/rust-sdk/pull/814))
15+
16+
### Other
17+
18+
- add systemprompt-template to Built with rmcp ([#820](https://github.com/modelcontextprotocol/rust-sdk/pull/820))
19+
1020
## [1.5.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v1.4.0...rmcp-macros-v1.5.0) - 2026-04-16
1121

1222
### Fixed

crates/rmcp/CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- *(server)* Normalise bare boolean subschemas (`true` / `false`) in generated `inputSchema`, `outputSchema`, and `ElicitationSchema` to their object-form equivalents (`{}` / `{"not": {}}`) before serialisation. Triggered by `serde_json::Value` field expansions, `Vec<serde_json::Value>`, `BTreeMap<String, serde_json::Value>`, and `#[serde(deny_unknown_fields)]`. Claude Code's `LocalMcpServerManager` schema walker throws `TypeError: Cannot use 'in' operator to search for 'properties' in <bool>` on bare booleans, silently dropping the entire server's tool list ([anthropics/claude-code#50194](https://github.com/anthropics/claude-code/issues/50194), [#25081](https://github.com/anthropics/claude-code/issues/25081)). The fix is unconditional — boolean subschemas are spec-legal per JSON Schema 2020-12 §4.3.2, but real-world MCP clients can't always handle them, so emit object form universally.
1313

14+
## [1.6.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.5.0...rmcp-v1.6.0) - 2026-05-01
15+
16+
### Added
17+
18+
- *(http)* log Host/Origin rejections ([#826](https://github.com/modelcontextprotocol/rust-sdk/pull/826))
19+
- *(http)* add Origin header validation ([#823](https://github.com/modelcontextprotocol/rust-sdk/pull/823))
20+
- *(router)* support runtime disabling of tools ([#809](https://github.com/modelcontextprotocol/rust-sdk/pull/809))
21+
- optional session store (resumabillity support) ([#775](https://github.com/modelcontextprotocol/rust-sdk/pull/775))
22+
23+
### Fixed
24+
25+
- add init_timeout for streamable-http sessions ([#811](https://github.com/modelcontextprotocol/rust-sdk/pull/811))
26+
- *(http)* fall back to :authority for HTTP/2 ([#827](https://github.com/modelcontextprotocol/rust-sdk/pull/827))
27+
- *(docs)* use correct Parameters<T> syntax in tool examples ([#814](https://github.com/modelcontextprotocol/rust-sdk/pull/814))
28+
29+
### Other
30+
31+
- add systemprompt-template to Built with rmcp ([#820](https://github.com/modelcontextprotocol/rust-sdk/pull/820))
32+
1433
## [1.5.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.4.0...rmcp-v1.5.0) - 2026-04-16
1534

1635
### Added

crates/rmcp/src/transport/streamable_http_server/session/local.rs

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -930,6 +930,8 @@ pub enum LocalSessionWorkerError {
930930
FailToHandleMessage(SessionError),
931931
#[error("keep alive timeout after {}ms", _0.as_millis())]
932932
KeepAliveTimeout(Duration),
933+
#[error("init timeout after {}ms", _0.as_millis())]
934+
InitTimeout(Duration),
933935
#[error("Transport closed")]
934936
TransportClosed,
935937
#[error("Tokio join error {0}")]
@@ -959,13 +961,24 @@ impl Worker for LocalSessionWorker {
959961
FromHttpService(SessionEvent),
960962
FromHandler(WorkerSendRequest<LocalSessionWorker>),
961963
}
962-
// waiting for initialize request
963-
let evt = self.event_rx.recv().await.ok_or_else(|| {
964-
WorkerQuitReason::fatal(
965-
LocalSessionWorkerError::TransportTerminated,
966-
"get initialize request",
967-
)
968-
})?;
964+
let init_timeout = self.session_config.init_timeout.unwrap_or(Duration::MAX);
965+
let evt = tokio::select! {
966+
evt = self.event_rx.recv() => evt.ok_or_else(|| {
967+
WorkerQuitReason::fatal(
968+
LocalSessionWorkerError::TransportTerminated,
969+
"get initialize request",
970+
)
971+
})?,
972+
_ = context.cancellation_token.cancelled() => {
973+
return Err(WorkerQuitReason::Cancelled);
974+
}
975+
_ = tokio::time::sleep(init_timeout) => {
976+
return Err(WorkerQuitReason::fatal(
977+
LocalSessionWorkerError::InitTimeout(init_timeout),
978+
"waiting for initialize request",
979+
));
980+
}
981+
};
969982
let SessionEvent::InitializeRequest { request, responder } = evt else {
970983
return Err(WorkerQuitReason::fatal(
971984
LocalSessionWorkerError::UnexpectedEvent(evt),
@@ -1122,13 +1135,18 @@ pub struct SessionConfig {
11221135
/// resume requests. After this duration, completed entries are evicted
11231136
/// and resume will return an error. Default is 60 seconds.
11241137
pub completed_cache_ttl: Duration,
1138+
/// Maximum duration to wait for the `initialize` request after session
1139+
/// creation. If not received within this window, the session is
1140+
/// terminated. Default is 60 seconds. Set to `None` to disable.
1141+
pub init_timeout: Option<Duration>,
11251142
}
11261143

11271144
impl SessionConfig {
11281145
pub const DEFAULT_CHANNEL_CAPACITY: usize = 16;
11291146
pub const DEFAULT_KEEP_ALIVE: Duration = Duration::from_secs(300);
11301147
pub const DEFAULT_SSE_RETRY: Duration = Duration::from_secs(3);
11311148
pub const DEFAULT_COMPLETED_CACHE_TTL: Duration = Duration::from_secs(60);
1149+
pub const DEFAULT_INIT_TIMEOUT: Duration = Duration::from_secs(60);
11321150
}
11331151

11341152
impl Default for SessionConfig {
@@ -1138,6 +1156,7 @@ impl Default for SessionConfig {
11381156
keep_alive: Some(Self::DEFAULT_KEEP_ALIVE),
11391157
sse_retry: Some(Self::DEFAULT_SSE_RETRY),
11401158
completed_cache_ttl: Self::DEFAULT_COMPLETED_CACHE_TTL,
1159+
init_timeout: Some(Self::DEFAULT_INIT_TIMEOUT),
11411160
}
11421161
}
11431162
}

crates/rmcp/src/transport/streamable_http_server/tower.rs

Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -328,25 +328,47 @@ fn bad_request_response(message: &str) -> BoxResponse {
328328
.expect("failed to build bad request response")
329329
}
330330

331-
fn parse_host_header(headers: &HeaderMap) -> Result<NormalizedAuthority, BoxResponse> {
332-
let Some(host) = headers.get(http::header::HOST) else {
333-
return Err(bad_request_response("Bad Request: missing Host header"));
334-
};
335-
336-
let host = host
337-
.to_str()
338-
.map_err(|_| bad_request_response("Bad Request: Invalid Host header encoding"))?;
339-
let authority = http::uri::Authority::try_from(host)
340-
.map_err(|_| bad_request_response("Bad Request: Invalid Host header"))?;
331+
fn parse_host_header(
332+
uri: &http::Uri,
333+
headers: &HeaderMap,
334+
) -> Result<NormalizedAuthority, BoxResponse> {
335+
if let Some(host) = headers.get(http::header::HOST) {
336+
let host_str = host
337+
.to_str()
338+
.inspect_err(|_| {
339+
tracing::warn!(host = ?host, "rejected request with non-UTF-8 Host header");
340+
})
341+
.map_err(|_| bad_request_response("Bad Request: Invalid Host header encoding"))?;
342+
let authority = http::uri::Authority::try_from(host_str)
343+
.inspect_err(|_| {
344+
tracing::warn!(
345+
host = host_str,
346+
"rejected request with malformed Host header"
347+
);
348+
})
349+
.map_err(|_| bad_request_response("Bad Request: Invalid Host header"))?;
350+
return Ok(normalize_authority(authority.host(), authority.port_u16()));
351+
}
352+
// HTTP/2 carries the host in `:authority`; middleware such as
353+
// `axum::Router::nest` can drop the `Host` header hyper synthesizes from it.
354+
let authority = uri.authority().ok_or_else(|| {
355+
tracing::warn!("rejected request with missing Host header and no :authority");
356+
bad_request_response("Bad Request: missing Host header")
357+
})?;
341358
Ok(normalize_authority(authority.host(), authority.port_u16()))
342359
}
343360

344361
fn validate_dns_rebinding_headers(
362+
uri: &http::Uri,
345363
headers: &HeaderMap,
346364
config: &StreamableHttpServerConfig,
347365
) -> Result<(), BoxResponse> {
348-
let host = parse_host_header(headers)?;
366+
let host = parse_host_header(uri, headers)?;
349367
if !host_is_allowed(&host, &config.allowed_hosts) {
368+
tracing::warn!(
369+
host = ?host,
370+
"rejected request with disallowed Host header (possible DNS rebinding attempt)",
371+
);
350372
return Err(forbidden_response("Forbidden: Host header is not allowed"));
351373
}
352374
validate_origin_header(headers, &config.allowed_origins)?;
@@ -365,10 +387,22 @@ fn validate_origin_header(
365387
};
366388
let origin_str = origin_header
367389
.to_str()
390+
.inspect_err(|_| {
391+
tracing::warn!(origin = ?origin_header, "rejected request with non-UTF-8 Origin header");
392+
})
368393
.map_err(|_| bad_request_response("Bad Request: Invalid Origin header encoding"))?;
369-
let origin = parse_origin_value(origin_str)
370-
.ok_or_else(|| bad_request_response("Bad Request: Invalid Origin header"))?;
394+
let origin = parse_origin_value(origin_str).ok_or_else(|| {
395+
tracing::warn!(
396+
origin = origin_str,
397+
"rejected request with malformed Origin header",
398+
);
399+
bad_request_response("Bad Request: Invalid Origin header")
400+
})?;
371401
if !origin_is_allowed(&origin, allowed_origins) {
402+
tracing::warn!(
403+
origin = ?origin,
404+
"rejected request with disallowed Origin header (possible cross-origin attack)",
405+
);
372406
return Err(forbidden_response(
373407
"Forbidden: Origin header is not allowed",
374408
));
@@ -780,7 +814,9 @@ where
780814
B: Body + Send + 'static,
781815
B::Error: Display,
782816
{
783-
if let Err(response) = validate_dns_rebinding_headers(request.headers(), &self.config) {
817+
if let Err(response) =
818+
validate_dns_rebinding_headers(request.uri(), request.headers(), &self.config)
819+
{
784820
return response;
785821
}
786822
let method = request.method().clone();

crates/rmcp/tests/test_custom_headers.rs

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,6 +1031,95 @@ async fn test_server_validates_host_header_port_for_dns_rebinding_protection() {
10311031
assert_eq!(response.status(), http::StatusCode::FORBIDDEN);
10321032
}
10331033

1034+
/// Integration test: Verify the validator falls back to the URI authority when
1035+
/// the Host header is absent (HTTP/2 :authority pseudo-header scenario).
1036+
#[tokio::test]
1037+
#[cfg(all(feature = "transport-streamable-http-server", feature = "server",))]
1038+
async fn test_server_falls_back_to_uri_authority_when_host_header_missing() {
1039+
use std::sync::Arc;
1040+
1041+
use bytes::Bytes;
1042+
use http::{Method, Request, header::CONTENT_TYPE};
1043+
use http_body_util::Full;
1044+
use rmcp::{
1045+
handler::server::ServerHandler,
1046+
model::{ServerCapabilities, ServerInfo},
1047+
transport::streamable_http_server::{
1048+
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
1049+
},
1050+
};
1051+
use serde_json::json;
1052+
1053+
#[derive(Clone)]
1054+
struct TestHandler;
1055+
1056+
impl ServerHandler for TestHandler {
1057+
fn get_info(&self) -> ServerInfo {
1058+
ServerInfo::new(ServerCapabilities::builder().build())
1059+
}
1060+
}
1061+
1062+
let service = StreamableHttpService::new(
1063+
|| Ok(TestHandler),
1064+
Arc::new(LocalSessionManager::default()),
1065+
StreamableHttpServerConfig::default(),
1066+
);
1067+
1068+
let init_body = json!({
1069+
"jsonrpc": "2.0",
1070+
"id": 1,
1071+
"method": "initialize",
1072+
"params": {
1073+
"protocolVersion": "2025-03-26",
1074+
"capabilities": {},
1075+
"clientInfo": {
1076+
"name": "test-client",
1077+
"version": "1.0.0"
1078+
}
1079+
}
1080+
});
1081+
1082+
// Allowed authority via URI only — no Host header.
1083+
let allowed_request = Request::builder()
1084+
.method(Method::POST)
1085+
.uri("http://localhost:8080/")
1086+
.header("Accept", "application/json, text/event-stream")
1087+
.header(CONTENT_TYPE, "application/json")
1088+
.body(Full::new(Bytes::from(init_body.to_string())))
1089+
.unwrap();
1090+
assert!(allowed_request.headers().get("Host").is_none());
1091+
1092+
let response = service.handle(allowed_request).await;
1093+
assert_eq!(response.status(), http::StatusCode::OK);
1094+
1095+
// Disallowed authority via URI only — no Host header.
1096+
let bad_request = Request::builder()
1097+
.method(Method::POST)
1098+
.uri("http://attacker.example/")
1099+
.header("Accept", "application/json, text/event-stream")
1100+
.header(CONTENT_TYPE, "application/json")
1101+
.body(Full::new(Bytes::from(init_body.to_string())))
1102+
.unwrap();
1103+
assert!(bad_request.headers().get("Host").is_none());
1104+
1105+
let response = service.handle(bad_request).await;
1106+
assert_eq!(response.status(), http::StatusCode::FORBIDDEN);
1107+
1108+
// Neither Host header nor URI authority — still a 400.
1109+
let missing_request = Request::builder()
1110+
.method(Method::POST)
1111+
.uri("/")
1112+
.header("Accept", "application/json, text/event-stream")
1113+
.header(CONTENT_TYPE, "application/json")
1114+
.body(Full::new(Bytes::from(init_body.to_string())))
1115+
.unwrap();
1116+
assert!(missing_request.headers().get("Host").is_none());
1117+
assert!(missing_request.uri().authority().is_none());
1118+
1119+
let response = service.handle(missing_request).await;
1120+
assert_eq!(response.status(), http::StatusCode::BAD_REQUEST);
1121+
}
1122+
10341123
#[cfg(all(feature = "transport-streamable-http-server", feature = "server"))]
10351124
mod origin_validation {
10361125
use std::sync::Arc;
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
#![cfg(all(feature = "transport-streamable-http-server", not(feature = "local")))]
2+
3+
use std::time::Duration;
4+
5+
use rmcp::{
6+
model::{ClientJsonRpcMessage, ClientRequest, PingRequest, RequestId},
7+
transport::streamable_http_server::session::{SessionManager, local::LocalSessionManager},
8+
};
9+
10+
#[tokio::test]
11+
async fn test_init_timeout_terminates_pre_init_session() -> anyhow::Result<()> {
12+
let mut manager = LocalSessionManager::default();
13+
manager.session_config.init_timeout = Some(Duration::from_millis(200));
14+
15+
// Bind the transport so its drop-guard doesn't cancel the worker — we
16+
// want termination via init_timeout, not via cancellation.
17+
let (session_id, _transport) = manager.create_session().await?;
18+
19+
tokio::time::sleep(Duration::from_millis(500)).await;
20+
21+
let message = ClientJsonRpcMessage::request(
22+
ClientRequest::PingRequest(PingRequest::default()),
23+
RequestId::Number(1),
24+
);
25+
let result = manager.initialize_session(&session_id, message).await;
26+
27+
assert!(
28+
result.is_err(),
29+
"expected worker to be dead; got: {result:?}"
30+
);
31+
32+
Ok(())
33+
}
34+
35+
#[tokio::test]
36+
async fn test_init_timeout_none_keeps_worker_alive() -> anyhow::Result<()> {
37+
let mut manager = LocalSessionManager::default();
38+
manager.session_config.init_timeout = None;
39+
40+
let (session_id, _transport) = manager.create_session().await?;
41+
42+
tokio::time::sleep(Duration::from_millis(500)).await;
43+
44+
let message = ClientJsonRpcMessage::request(
45+
ClientRequest::PingRequest(PingRequest::default()),
46+
RequestId::Number(1),
47+
);
48+
// Liveness probe: a live worker accepts the send then stalls waiting for
49+
// a handler response (none is wired up), tripping the outer timeout. A
50+
// dead worker would fail the send and return immediately.
51+
let probe = tokio::time::timeout(
52+
Duration::from_millis(200),
53+
manager.initialize_session(&session_id, message),
54+
)
55+
.await;
56+
57+
assert!(
58+
probe.is_err(),
59+
"expected worker to be alive; got: {probe:?}"
60+
);
61+
62+
Ok(())
63+
}

0 commit comments

Comments
 (0)