* fix(elicitation): preserve enumNames through ElicitationSchema serde round-trip
UntitledSingleSelectEnumSchema lacked deny_unknown_fields, so a legacy
enum payload containing enumNames was silently matched by that variant
(ignoring the field) rather than falling through to LegacyEnumSchema.
The enumNames array was lost on re-serialization.
Add deny_unknown_fields to UntitledSingleSelectEnumSchema so that any
unknown field (including enumNames) causes serde to try the next
untagged variant, reaching LegacyEnumSchema correctly.
Also add skip_serializing_if = "Option::is_none" to
LegacyEnumSchema::enum_names so that an untitled legacy enum without
enumNames does not serialize "enumNames": null.
Fixes#903
* test(elicitation): regenerate server schema snapshot for deny_unknown_fields
deny_unknown_fields on UntitledSingleSelectEnumSchema makes schemars
emit additionalProperties: false for that definition. Regenerate the
golden schema fixtures to match (UPDATE_SCHEMA=1 cargo test -p rmcp
--test test_message_schema --all-features).
* feat: implement SEP-2164 resource not found errors
* test: update protocol version utility expectations
* feat: gate not-found code at server boundary
---------
Co-authored-by: Michael Neale <michael.neale@gmail.com>
SEP-2577 deprecates the Roots, Sampling, and Logging features. The
deprecation is advisory: the features stay fully functional and there is
no wire-level change. Mark the corresponding Rust APIs as deprecated so
downstream users get compiler warnings and migration guidance.
- Forward attributes through the service `method!` macros and deprecate
`Peer::create_message`, `Peer::list_roots`, `Peer::set_level`, and
`Peer::notify_logging_message`.
- Forward per-field attributes through the capability `builder!` macro and
deprecate the generated `enable_roots`, `enable_sampling`, and
`enable_logging` builders, plus the hand-written
`enable_roots_list_changed`, `enable_sampling_tools`, and
`enable_sampling_context`.
- Document the deprecation on the capability types and fields, and in the
README feature sections.
- Allow `deprecated` at the crate's own call sites so the build stays
warning-clean, and refresh the message schema snapshots.
The Resource variant of PromptMessageContent was missing #[serde(flatten)],
causing the embedded resource content block to serialize as a double-nested
shape `{ "type": "resource", "resource": { "resource": {...} } }` instead of
the spec-compliant flat shape `{ "type": "resource", "resource": {uri, mimeType, text} }`.
This caused Zod-based MCP clients (e.g. Claude Code) to reject prompts/get
responses containing embedded resource messages with InvalidUnion errors.
The Image and ResourceLink variants already use #[serde(flatten)] correctly;
only Resource was missing it.
Fix: add #[serde(flatten)] so EmbeddedResource (=Annotated<RawEmbeddedResource>)
fields _meta / annotations / resource are flattened to the content-block level,
matching the MCP spec for prompts embedded resources.
Regression test: test_prompt_message_resource_serialization_is_flat verifies
content.resource.uri is reachable and content.resource.resource is absent.
Schema snapshots regenerated via UPDATE_SCHEMA=1.
* fix(transport): downgrade idle timeout log from error to debug
Idle keep-alive timeout is normal zombie-session cleanup, not a transport failure.
Route it through a dedicated WorkerQuitReason::IdleTimeout variant.
Log it at debug level instead of treating it as a fatal error.
Remove the unused LocalSessionWorkerError::KeepAliveTimeout variant.
Closes#817
* fix(session): tolerate dead worker in close_session
Swallow SessionServiceTerminated in close_session when the worker has already exited.
This prevents a spurious ERROR log during the post-exit cleanup path in
spawn_session_worker.
* fix(transport): address PR review feedback
- deprecate KeepAliveTimeout
- harden tests
* feat(router): support runtime disabling of tools
Add methods to disable/enable tools at runtime.
Disabled tools are hidden from listing, lookup,
and execution, including in composed routers.
Closes#477
* fix(router): simplify disable tool api
* feat(router): auto-send tools/list_changed on disable/enable
* refactor(router): simplify disable_route and notifier call
* fix: include http_request_id in request-wise priming event IDs
* refactor: use Option::into_iter and usize::from for priming
* fix: retain event cache for completed request-wise channels
* fix: track completed_at for cache eviction and resume
* fix: log resume failures at warn level
* test: add completed_cache_ttl eviction test
* fix: return empty stream on failed resume
* test: add resume after completion test
* fix(http): reduce latency on subsequent StreamableHttp calls
* refactor: rely on stream drain for connection reuse
* refactor: clean up comments and naming
* fix: restore pool_max_idle_per_host(0) for Linux
* fix(server): remove initialized notification gate to support Streamable HTTP
The server's init handshake loop fatally rejected any request arriving
before the `notifications/initialized` message. This breaks Streamable
HTTP clients where each JSON-RPC message is a separate POST with no
ordering guarantee — `tools/list` can easily arrive before `initialized`.
Remove the ~40-line wait loop and enter `serve_inner` immediately after
sending `InitializeResult`. The `initialized` notification is now
handled as a regular notification by the main service loop, matching the
TypeScript SDK behavior (validated in typescript-sdk#578).
Also remove the now-unreachable `ExpectedInitializedNotification` error
variant from `ServerInitializeError`.
Closes#783
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(server): keep ExpectedInitializedNotification as deprecated
Retain the variant for semver compatibility — removing it would be a
breaking change caught by cargo-semver-checks. Mark it deprecated with
a note that it is never constructed and will be removed in a future
major release.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Anar Azadaliyev <anar.azadaliye@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(macros): auto-generate get_info and default router
* docs: simplify examples and docs with new defaults
* feat(macros): add tool_router(server_handler) to elide separate #[tool_handler] impl
* docs: add Tools section to README and simplify calculator examples with server_handler
* feat: add theme field to Icon
* fix: update IconThem crates/rmcp/src/model.rs (non_exhaustive)
Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com>
* fix: update IconThem crates/rmcp/src/model.rs (eq, hash)
Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com>
* fix: update docs with full descriptions of theme from mcp spec
---------
Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com>
* feat(transport): add Unix domain socket client for streamable HTTP
MCP hosts in Kubernetes environments with Envoy sidecars need to route
HTTP through Unix domain sockets because DNS-based URIs only resolve
via the proxy. Adds UnixSocketHttpClient implementing StreamableHttpClient
using hyper over tokio::net::UnixStream, gated behind the
transport-streamable-http-client-unix-socket feature.
Also extracts RESERVED_HEADERS, extract_scope_from_header, and
validate_custom_header into common/http_header.rs to share header
validation logic between the reqwest and unix socket implementations.
* fix(transport): address review feedback for unix socket transport
- Document one-connection-per-request behavior on UnixSocketHttpClient
- Reject empty socket paths and bare '@' in constructor with assert
- Add explicit dep:http to unix-socket feature for self-documenting deps
- Document MCP-Protocol-Version exception on RESERVED_HEADERS constant
- Fix test catch-all to echo request id instead of hardcoding 1
- Remove leftover sleep(100ms) in test_unix_socket_custom_headers
- Add blank line before macro comment in Cargo.toml
* fix(transport): fix CI failures for unix socket transport
- Use std::io::Error::other() instead of Error::new(ErrorKind::Other)
to satisfy clippy::io_other_error on newer nightly
- Use #[tokio::test(flavor = "current_thread")] for unix socket tests
since axum's serve(UnixListener) requires spawn_local
- Gate validate_custom_header behind client-side-sse feature since it
references http::HeaderName which isn't available with default features
* fix(transport): fix CI failures for unix socket transport
axum::serve(UnixListener) uses spawn_local on Linux, which panics
outside a LocalSet. Replace with manual hyper HTTP/1.1 server that
accepts connections directly from the UnixListener, avoiding the
spawn_local requirement entirely.
* fix(transport): skip unix socket tests when local feature is enabled
The local feature causes ().serve(transport) to use spawn_local, which
requires a LocalSet. Gate the integration tests with not(feature = "local")
to match every other integration test in the repo.
* feat: add configuration for transparent session re-init
* fix: in ci revert running tests without local until all tests pass
* fix: pr comments
* fix: documentation
* fix(rmcp): surface JSON-RPC error bodies on HTTP 4xx responses
When a server returns a 4xx status with Content-Type: application/json,
attempt to deserialize the body as a ServerJsonRpcMessage before falling
back to UnexpectedServerResponse. This allows JSON-RPC error payloads
carried on HTTP error responses to be surfaced as McpError instead of
being lost in a transport-level error string.
Fixes#724
* fix(rmcp): surface JSON-RPC error bodies on HTTP 4xx responses
When a server returns a 4xx status with Content-Type: application/json,
attempt to deserialize the body as a ServerJsonRpcMessage before falling
back to UnexpectedServerResponse. This allows JSON-RPC error payloads
carried on HTTP error responses to be surfaced as McpError instead of
being lost in a transport-level error string.
Fixes#724
* fix(rmcp): only accept JsonRpcMessage::Error on non-success responses
* fix: builder with_* methods take T instead of Option<T>
* fix: emit conditional builder calls for optional fields in macros
* fix: convert with_task, with_stop_reason, with_logger, with_content to proper builders
* fix: update test callers for new builder signatures
* fix: simplify make_task helper and remove unused import
* fix: update sampling_stdio example for new with_stop_reason signature
* fix: make annotations and execution Option<Expr> consistent with other fields
* fix: remove unused none_expr import
* feat(streamable-http): add json_response option for stateless server mode
Adds `json_response: bool` field to `StreamableHttpServerConfig`.
When true and `stateful_mode` is false, the server returns
`Content-Type: application/json` directly instead of `text/event-stream`,
eliminating SSE framing overhead for simple request-response patterns.
This completes server-side JSON response support (client-side was added
in #540) and contributes to the stateless server goals of SEP-1442 (#526).
Backwards-compatible: `json_response: false` (default) preserves all
existing SSE behaviour unchanged, and `stateful_mode: true` is unaffected.
Benchmark evidence (50 VUs, 5min, 2 CPUs):
- RPS: 770 → 1139 (+48%)
- get_user_cart latency: 41ms → 0.76ms (-98%)
- checkout latency: 41ms → 0.55ms (-99%)
- Zero regressions, zero errors
* fix(tower): add cancellation awareness and logging to JSON response path
* fix(test): add missing Default to StreamableHttpServerConfig in concurrent streams test
Made-with: Cursor
* fix(streamable-http): return 409 Conflict when standalone SSE stream already active
LocalSessionWorker::resume() unconditionally replaced self.common.tx on
every GET request, orphaning the receiver the first SSE stream was
reading from. All subsequent server-to-client notifications were sent to
the new sender while the original client was still listening on the old,
now-dead receiver. notify_tool_list_changed().await returned Ok(())
silently.
This is triggered by VS Code's MCP extension which reconnects SSE every
~5 minutes with the same session ID.
Fix: Check tx.is_closed() before replacing the common channel sender.
If an active stream exists, return SessionError::Conflict which is
propagated as HTTP 409 Conflict. This matches the TypeScript SDK
behavior (streamableHttp.ts:423).
Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
* fix(streamable-http): handle resume with completed request-wise channel
When a client sends GET with Last-Event-ID from a completed POST SSE
response, the request-wise channel no longer exists in tx_router.
Previously this returned ChannelClosed -> 500, causing clients like
Cursor to enter an infinite re-initialization loop.
Now falls back to the common channel when the request-wise channel is
completed, per MCP spec: "Resumption applies regardless of how the
original stream was initiated (POST or GET)."
* fix: allow SSE channel replacement instead of 409 Conflict
Per MCP spec §Streamable HTTP, "The client MAY remain connected to
multiple SSE streams simultaneously." Returning 409 Conflict when a
second GET arrives causes Cursor to enter an infinite re-initialization
loop (~3s cycle).
Instead of rejecting, replace the old common channel sender. Dropping
the old sender closes the old receiver, cleanly terminating the
previous SSE stream so the client can reconnect on the new stream.
This fixes both code paths:
- GET with Last-Event-ID from a completed POST SSE response
- GET without Last-Event-ID (standalone stream reconnection)
* fix: skip cache replay when replacing active SSE stream
When a client opens a new GET SSE stream while a previous one is
still active, the old sender is dropped (terminating the old stream)
and a new channel is created. Previously, sync() replayed all cached
events to the new stream, but the client already received those events
on the old stream. This caused an infinite notification loop:
1. Client receives notifications (e.g. ResourceListChanged)
2. Old SSE stream dies (sender replaced)
3. Client reconnects after sse_retry (3s)
4. sync() replays cached notifications the client already handled
5. Client processes them again → goto 2
Fix: check tx.is_closed() BEFORE replacing the sender. If the old
stream was still alive, skip replay entirely — the client already has
those events. Only replay when the old stream was genuinely dead
(network failure, timeout) so the client catches up on missed events.
* fix: use shadow channels to prevent SSE reconnect loops
When POST SSE responses include a `retry` field, the browser's
EventSource automatically reconnects via GET after the stream ends.
This creates multiple competing EventSource connections that each
replace the common channel sender, killing the other stream's receiver.
Both reconnect every sse_retry seconds, creating an infinite loop.
Instead of always replacing the common channel, check if the primary
is still active. If so, create a "shadow" stream — an idle SSE
connection kept alive by keep-alive pings that doesn't receive
notifications or interfere with the primary channel.
Also removes cache replay (sync) on common channel resume, as
replaying server-initiated list_changed notifications causes clients
to re-process old signals.
Signed-off-by: Myko Ash <myko@mcpmux.com>
Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
* test: comprehensive shadow channel tests (15 cases)
Rewrite test suite for SSE channel replacement fix:
- Shadow creation: standalone GET returns 200, multiple GETs coexist
- Dead primary: replacement, notification delivery, repeated cycles
- Notification routing: primary receives, shadow does not
- Resume paths: completed request-wise, common alive/dead
- Real scenarios: Cursor leapfrog, VS Code reconnect
- Edge cases: invalid session, missing header, shadow cleanup
Fix Accept header bug (was missing text/event-stream for
notifications/initialized POST, causing 406 rejection).
* fix: use correct HTTP status codes for session errors per MCP spec
MCP spec (2025-11-25) section "Session Management" requires:
- Missing session ID header → 400 Bad Request (not 401)
- Unknown/terminated session → 404 Not Found (not 401)
Using 401 Unauthorized caused MCP clients (e.g. VS Code) to
trigger full OAuth re-authentication on server restart, instead
of simply re-initializing the session.
Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
* fix: address review feedback — remove dead Conflict variant, restore sync on resume, rename test
- Remove unused SessionError::Conflict and dead string-matching in tower.rs
(leftover from abandoned 409 approach)
- Restore sync() replay when replacing a dead primary common channel so
server-initiated requests and cached notifications are not lost on reconnect
- Rename test from test_sse_channel_replacement_bug to test_sse_concurrent_streams
per reviewer suggestion (describe what tests verify, not what triggered them)
- Add test for cache replay on dead primary replacement
- Use generic "MCP clients" in comments instead of specific client names
Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
* fix: use minimal buffer for shadow streams and cap at 32
- Shadow streams only receive SSE keep-alive pings, so use capacity 1
instead of full channel_capacity
- Cap shadow_txs at 32 to prevent unbounded growth from misbehaving
clients, dropping the oldest shadow when the limit is reached
- Add test verifying primary works after exceeding shadow limit
Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
* fix: remove redundant single-component `use reqwest` import
Fixes clippy::single_component_path_imports lint error in
test_sse_concurrent_streams.rs.
---------
Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
Signed-off-by: Myko Ash <myko@mcpmux.com>
Co-authored-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
ToolRouter::list_all() and PromptRouter::list_all() iterate over a
HashMap, which returns items in non-deterministic order. Since list_all()
backs the tools/list and prompts/list MCP protocol responses, this causes
MCP clients to receive differently-ordered results across calls and
process restarts, leading to intermittent tool discovery failures.
Sort the output alphabetically by name to guarantee stable ordering.