You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This issue has had no activity for 60 days and is now marked as stale. It will be closed in 14 days if there is no further activity. Add the keep label to keep it open.
Limiting on Transport::send() indeed simplifies the implementation of rate limiting and helps implementations conform to the specification.
With that being said, I would lean towards making it a configurable building block, rather than having the SDK, which cannot know an application's specific requirements or capacity constraints, enforce a single policy.
For example, different applications may want to rate limit based on the client's IP or the authenticated principal.
Similarly, not all operations necessarily have the same computational cost. Some tool invocations may be computationally expensive or return large amounts of data, while others may be lightweight or heavily cached.
It could therefore be useful if applications can configure different policies for different message types, while the SDK simply provides the hooks to integrate a rate limiting layer. I believe this would keep the SDK flexible while making it easier for implementations to comply with the specification.
I would like to work on this. One design can cover what @bug-ops and @darth-raijin each ask for. Before writing anything real, though, I want to flag that the spec rules quoted in the issue body have changed since it was filed in 2025-08 against spec 2025-06-18. I read them again against 2026-07-28, which is the version rmcp targets (ProtocolVersion::V_2026_07_28):
Still there, but Logging is deprecated by SEP-2577. rmcp already marks those types #[deprecated]
Sampling: "Clients SHOULD implement rate limiting"
Still there, but Sampling is deprecated by SEP-2577, and it now travels inside InputRequiredResult (MRTR), not as a sampling/createMessage JSON-RPC request
Elicitation: "Clients SHOULD implement rate limiting"
Gone. It was 2025-06-18 §Security bullet 6. There is no such bullet in 2026-07-28. Elicitation also travels inside InputRequiredResult now
Two things follow from that, and I think they should shape the design:
The only MUST is inbound, on the server side.completion/complete is a ClientRequest, so a server receives it. A limiter that only runs in Transport::send() cannot meet that MUST for a server. It cannot meet it for a client either, because the client is the sender.
Of the five rules, only Progress is served well by an outbound wrapper. Two are deprecated, one is gone, and the MUST is inbound. So a token bucket in send() on its own buys less than the issue body suggests. That is not a reason to drop it. It is a reason to scope it honestly and to say what we do about inbound traffic.
Proposal
Two constraints from the thread: the SDK should not force one policy on everyone (@darth-raijin), and a transport wrapper is the natural place for the outbound half (@bug-ops). Both hold if the SDK ships the mechanism plus an opt-in default, and changes nothing by default.
Two small traits. They are split so that key extraction can borrow the message (sync), while the limiter itself can be async, which a Redis or other shared limiter has to be:
/// Step 1 (sync, borrows the message): work out an owned limiter key./// This is the hook for keying on client IP, authenticated principal, or per-tool/// cost. Those live in `Extensions`, and the SDK cannot know about them.pubtraitRateLimitKeyExtractor<R:ServiceRole>:Send + Sync + 'static{typeKey:Send + 'static;fnextract(&self,direction:Direction,message:&TxJsonRpcMessage<R>) -> Option<Self::Key>;}/// Step 2 (async, owns the key): ask for capacity.pubtraitRateLimiter<K>:Send + Sync + 'static{fnacquire(&self,key:K) -> implFuture<Output = RateLimitDecision> + Send;}#[non_exhaustive]pubenumRateLimitDecision{Allow,Delay(Duration),// wait for capacity, then carry onDrop,// notifications only, see belowReject{retry_after:Option<Duration>},// typed error}
The wrapper follows SinkStreamTransport: hold an Arc<L> and clone it into the async block, and build the inner send future up front so concurrent sends still work. The send future is Send + 'static, so nothing can borrow from &mut self. The Transport trait itself does not change, so no existing transport breaks.
Drop is honoured for notifications and turned into Reject for requests. Dropping a notifications/progress is exactly what the spec wants. Quietly dropping a completion/complete would be a bug.
The default extractor keys on the spec message class, so the common case needs no user code:
let policies = HashMap::from([// SHOULD: shed progress spam rather than fail the send.(MessageClass::Progress,Policy::new(20,5.0,Exhausted::Drop)),// MUST: surfaced as a typed error, never dropped quietly.(MessageClass::Completion,Policy::new(10,2.0,Exhausted::Reject)),]);let transport = RateLimitedTransport::new(
inner_transport,ByMessageClass::default(),// or your own extractorTokenBucket::new(policies,None),// or your own limiter);
(Policy is built through Policy::new rather than a struct literal because the crate lints exhaustive_structs, so every public type here carries #[non_exhaustive].)
The working code is on a branch in my fork, so it can be read as a diff rather than as code blocks here: main...IgorKasianenko:rate-limit-388 (2 files, +489). It is one commit on top of b5cf34e, built in the repo's dev container image, and it compiles against the current Transport trait with no change to it. Three tests cover the under-limit case, the over-limit case, and the split between message types: a completion over its limit returns LimitExceeded, and a progress notification over its limit is dropped and reported as success.
I am not opening a PR until the shape is agreed here.
Two notes on how this sits with the rest of the crate:
No new dependency. The token bucket needs only std::time::Instant, tokio::sync::Mutex and tokio::time::sleep, and rmcp already depends on all of those. It should still sit behind a feature (rate-limit?), off by default, to match how the crate gates everything else.
One small addition is needed for classification.ClientRequest::method() exists today. ServerRequest, ClientNotification and ServerNotification have nothing like it. A GetMethod trait implemented for all four would let the wrapper work for either role. It is purely additive: the bound goes on the wrapper's impl, not on ServiceRole.
Questions before I write anything real
Inbound. Should rate limiting also apply to receive()? That is where the real DoS risk sits for a server, and it is the only place the completion MUST can be met. But receive() -> Option<RxJsonRpcMessage> can only slow down (backpressure) or drop. It cannot reply with a JSON-RPC error, which is what a refused completion/complete should get. Is a service-layer hook in scope, or should this PR cover outbound only and leave inbound for a follow-up?
Default behaviour. I assume opt-in only: no limiter unless the user wraps their transport, so nothing changes for current users. Would you rather have a default policy for completion/complete, given it is a MUST?
Deprecated features. Should Sampling and Logging classes be included at all, given SEP-2577? I would include them, because peers on older protocol versions still exist, but not document them as recommended.
Shape. Two traits plus a wrapper, or would you prefer one trait with a richer context object?
Is this the right way to go with it? I would rather settle the shape here than in a PR.
@DaleSeo@alexhancock — one small housekeeping point: .github/workflows/stale.yml exempts issues labelled keep, but that label does not exist in the repo yet, so nobody can apply it. This issue was marked stale on 2026-07-22 and only escaped because of the 07-30 comment. Could one of you create keep and put it on this issue, so the discussion does not get closed underneath it?
@IgorKasianenko, I read through your prototype and the inbound question you raised. I could help with a focused follow-up on the security test matrix rather than duplicate the transport design: verify that an exhausted completion/complete policy is surfaced as an error, while progress notifications are only dropped when the policy allows it, across the relevant inbound and outbound paths.
Would that be useful as a separate test-focused contribution, or do you prefer to keep inbound handling for a later service-layer follow-up?
Activity
alexhancock commentedon Feb 27, 2026
This is true - we need to look into this
github-actions commentedon Jul 22, 2026
This issue has had no activity for 60 days and is now marked as stale. It will be closed in 14 days if there is no further activity. Add the
keeplabel to keep it open.darth-raijin commentedon Jul 30, 2026
Hey, adding my two cents:
Limiting on
Transport::send()indeed simplifies the implementation of rate limiting and helps implementations conform to the specification.With that being said, I would lean towards making it a configurable building block, rather than having the SDK, which cannot know an application's specific requirements or capacity constraints, enforce a single policy.
For example, different applications may want to rate limit based on the client's IP or the authenticated principal.
Similarly, not all operations necessarily have the same computational cost. Some tool invocations may be computationally expensive or return large amounts of data, while others may be lightweight or heavily cached.
It could therefore be useful if applications can configure different policies for different message types, while the SDK simply provides the hooks to integrate a rate limiting layer. I believe this would keep the SDK flexible while making it easier for implementations to comply with the specification.
IgorKasianenko commentedon Aug 10, 2026
I would like to work on this. One design can cover what @bug-ops and @darth-raijin each ask for. Before writing anything real, though, I want to flag that the spec rules quoted in the issue body have changed since it was filed in 2025-08 against spec
2025-06-18. I read them again against2026-07-28, which is the versionrmcptargets (ProtocolVersion::V_2026_07_28):2026-07-28rmcpalready marks those types#[deprecated]InputRequiredResult(MRTR), not as asampling/createMessageJSON-RPC request2025-06-18§Security bullet 6. There is no such bullet in2026-07-28. Elicitation also travels insideInputRequiredResultnowTwo things follow from that, and I think they should shape the design:
completion/completeis aClientRequest, so a server receives it. A limiter that only runs inTransport::send()cannot meet that MUST for a server. It cannot meet it for a client either, because the client is the sender.send()on its own buys less than the issue body suggests. That is not a reason to drop it. It is a reason to scope it honestly and to say what we do about inbound traffic.Proposal
Two constraints from the thread: the SDK should not force one policy on everyone (@darth-raijin), and a transport wrapper is the natural place for the outbound half (@bug-ops). Both hold if the SDK ships the mechanism plus an opt-in default, and changes nothing by default.
Two small traits. They are split so that key extraction can borrow the message (sync), while the limiter itself can be async, which a Redis or other shared limiter has to be:
The wrapper follows
SinkStreamTransport: hold anArc<L>and clone it into the async block, and build the inner send future up front so concurrent sends still work. Thesendfuture isSend + 'static, so nothing can borrow from&mut self. TheTransporttrait itself does not change, so no existing transport breaks.Dropis honoured for notifications and turned intoRejectfor requests. Dropping anotifications/progressis exactly what the spec wants. Quietly dropping acompletion/completewould be a bug.The default extractor keys on the spec message class, so the common case needs no user code:
(
Policyis built throughPolicy::newrather than a struct literal because the crate lintsexhaustive_structs, so every public type here carries#[non_exhaustive].)The working code is on a branch in my fork, so it can be read as a diff rather than as code blocks here:
main...IgorKasianenko:rate-limit-388(2 files, +489). It is one commit on top ofb5cf34e, built in the repo's dev container image, and it compiles against the currentTransporttrait with no change to it. Three tests cover the under-limit case, the over-limit case, and the split between message types: a completion over its limit returnsLimitExceeded, and a progress notification over its limit is dropped and reported as success.just check,just testand thefmthalf ofjust fixare all clean with the module in place:I am not opening a PR until the shape is agreed here.
Two notes on how this sits with the rest of the crate:
std::time::Instant,tokio::sync::Mutexandtokio::time::sleep, andrmcpalready depends on all of those. It should still sit behind a feature (rate-limit?), off by default, to match how the crate gates everything else.ClientRequest::method()exists today.ServerRequest,ClientNotificationandServerNotificationhave nothing like it. AGetMethodtrait implemented for all four would let the wrapper work for either role. It is purely additive: the bound goes on the wrapper's impl, not onServiceRole.Questions before I write anything real
receive()? That is where the real DoS risk sits for a server, and it is the only place the completion MUST can be met. Butreceive() -> Option<RxJsonRpcMessage>can only slow down (backpressure) or drop. It cannot reply with a JSON-RPC error, which is what a refusedcompletion/completeshould get. Is a service-layer hook in scope, or should this PR cover outbound only and leave inbound for a follow-up?completion/complete, given it is a MUST?SamplingandLoggingclasses be included at all, given SEP-2577? I would include them, because peers on older protocol versions still exist, but not document them as recommended.Is this the right way to go with it? I would rather settle the shape here than in a PR.
@DaleSeo @alexhancock — one small housekeeping point:
.github/workflows/stale.ymlexempts issues labelledkeep, but that label does not exist in the repo yet, so nobody can apply it. This issue was marked stale on 2026-07-22 and only escaped because of the 07-30 comment. Could one of you createkeepand put it on this issue, so the discussion does not get closed underneath it?ECD5A commentedon Sep 22, 2026
@IgorKasianenko, I read through your prototype and the inbound question you raised. I could help with a focused follow-up on the security test matrix rather than duplicate the transport design: verify that an exhausted
completion/completepolicy is surfaced as an error, while progress notifications are only dropped when the policy allows it, across the relevant inbound and outbound paths.Would that be useful as a separate test-focused contribution, or do you prefer to keep inbound handling for a later service-layer follow-up?