001/*
002 * Copyright 2022-2026 Revetware LLC.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package com.soklet;
018
019import org.jspecify.annotations.NonNull;
020
021import javax.annotation.concurrent.ThreadSafe;
022import java.math.BigInteger;
023import java.time.Duration;
024import java.util.ArrayList;
025import java.util.List;
026import java.util.concurrent.ConcurrentHashMap;
027import java.util.concurrent.ConcurrentMap;
028import java.util.concurrent.locks.ReentrantReadWriteLock;
029
030import static java.util.Objects.requireNonNull;
031
032/**
033 * Thread-safe application rate limiter for MCP requests or tool invocations.
034 * <p>
035 * Soklet invokes the configured request limiter once for each admitted
036 * request or notification. It additionally invokes the resolved tool limiter
037 * for a tool call when one is configured. A {@code null} result or exception
038 * fails closed. Successful
039 * acquisitions are never refunded after later denial, failure, cancelation,
040 * timeout, or response-write failure.
041 * Small, valid, owner-verified 2025 session ping, unsubscribe, initialized and
042 * cancellation messages instead use independent bounded framework control
043 * quotas. Their bodies must be at most 16 KiB; fresh admission and session
044 * verification still apply. See {@link McpServer.Builder#requestRateLimiter(McpRateLimiter)}.
045 * <p>
046 * Implementations may keep state in-process or delegate to a distributed
047 * service. Soklet does not own or close application-supplied limiters.
048 *
049 * @author <a href="https://www.revetkn.com">Mark Allen</a>
050 */
051@ThreadSafe
052@FunctionalInterface
053public interface McpRateLimiter {
054        /**
055         * Attempts to acquire permission for one request or tool invocation.
056         *
057         * @param rateLimitContext immutable rate-limit context
058         * @return a non-null allowed or denied decision
059         * @throws Exception if the backing rate-limit service fails
060         */
061        @NonNull
062        McpRateLimitDecision acquire(@NonNull McpRateLimitContext rateLimitContext) throws Exception;
063
064        /**
065         * Creates Soklet's built-in in-memory token-bucket limiter.
066         * <p>
067         * The returned limiter partitions state by normalized endpoint path, the
068         * accepted admission identity's stable rate-limit partition key, and the
069         * context target. Retained partition state is bounded. At capacity, one
070         * acquisition examines a bounded rotating sample of retained partitions and
071         * reclaims the first fully replenished one it finds. A new partition fails
072         * closed when that sample contains none, even if another reclaimable
073         * partition exists outside the current sample. The limiter is local to this
074         * JVM and provides no cross-instance coordination.
075         *
076         * @param tokenBucketConfig finite token-bucket configuration
077         * @return a new independent in-memory limiter
078         */
079        @NonNull
080        static McpRateLimiter fromInMemoryTokenBucket(
081                        @NonNull McpTokenBucketConfig tokenBucketConfig) {
082                return new DefaultMcpRateLimiter(requireNonNull(tokenBucketConfig));
083        }
084}
085
086/**
087 * Built-in process-local token-bucket implementation.
088 *
089 * @author <a href="https://www.revetkn.com">Mark Allen</a>
090 */
091@ThreadSafe
092final class DefaultMcpRateLimiter implements McpRateLimiter {
093        private static final int MAXIMUM_RETAINED_PARTITIONS = 16_384;
094        private static final int MAXIMUM_PARTITION_RECLAIM_PROBES = 64;
095        @NonNull
096        private final McpTokenBucketConfig configuration;
097        private final long refillIntervalNanos;
098        @NonNull
099        private final BigInteger tokenUnit;
100        @NonNull
101        private final BigInteger capacityUnits;
102        @NonNull
103        private final BigInteger refillTokens;
104        @NonNull
105        private final ConcurrentMap<@NonNull BucketKey, @NonNull RetainedBucket> buckets;
106        @NonNull
107        private final List<@NonNull RetainedBucket> bucketSlots;
108        @NonNull
109        private final McpRateLimiterClock clock;
110        private final int maximumRetainedPartitions;
111        private final int maximumPartitionReclaimProbes;
112        @NonNull
113        private final ReentrantReadWriteLock partitionLock;
114        private int nextPartitionReclaimProbeSlot;
115
116        DefaultMcpRateLimiter(@NonNull McpTokenBucketConfig configuration) {
117                this(configuration, System::nanoTime, MAXIMUM_RETAINED_PARTITIONS);
118        }
119
120        DefaultMcpRateLimiter(@NonNull McpTokenBucketConfig configuration,
121                        @NonNull McpRateLimiterClock clock, int maximumRetainedPartitions) {
122                this(configuration, clock, maximumRetainedPartitions,
123                                Math.min(MAXIMUM_PARTITION_RECLAIM_PROBES,
124                                                maximumRetainedPartitions));
125        }
126
127        DefaultMcpRateLimiter(@NonNull McpTokenBucketConfig configuration,
128                        @NonNull McpRateLimiterClock clock, int maximumRetainedPartitions,
129                        int maximumPartitionReclaimProbes) {
130                this.configuration = requireNonNull(configuration);
131                this.refillIntervalNanos = configuration.getRefillInterval().toNanos();
132                this.tokenUnit = BigInteger.valueOf(this.refillIntervalNanos);
133                this.capacityUnits = this.tokenUnit.multiply(
134                                BigInteger.valueOf(configuration.getCapacity()));
135                this.refillTokens = BigInteger.valueOf(configuration.getRefillTokens());
136                if (maximumRetainedPartitions < 1)
137                        throw new IllegalArgumentException(
138                                        "maximumRetainedPartitions must be positive");
139                if (maximumPartitionReclaimProbes < 1)
140                        throw new IllegalArgumentException(
141                                        "maximumPartitionReclaimProbes must be positive");
142                this.buckets = new ConcurrentHashMap<>();
143                this.bucketSlots = new ArrayList<>(maximumRetainedPartitions);
144                this.clock = requireNonNull(clock);
145                this.maximumRetainedPartitions = maximumRetainedPartitions;
146                this.maximumPartitionReclaimProbes = Math.min(
147                                maximumPartitionReclaimProbes, maximumRetainedPartitions);
148                this.partitionLock = new ReentrantReadWriteLock();
149        }
150
151        @Override
152        @NonNull
153        public McpRateLimitDecision acquire(@NonNull McpRateLimitContext rateLimitContext) {
154                requireNonNull(rateLimitContext);
155                BucketKey key = new BucketKey(
156                                rateLimitContext.getEndpoint().getPath(),
157                                rateLimitContext.getAdmissionIdentity().getRateLimitPartitionKey(),
158                                rateLimitContext.getTarget());
159                long nowNanos = this.clock.nanoTime();
160
161                this.partitionLock.readLock().lock();
162                try {
163                        RetainedBucket retainedBucket = this.buckets.get(key);
164                        if (retainedBucket != null)
165                                return retainedBucket.bucket().acquire(nowNanos, this.capacityUnits,
166                                                this.tokenUnit, this.refillTokens);
167                } finally {
168                        this.partitionLock.readLock().unlock();
169                }
170
171                this.partitionLock.writeLock().lock();
172                try {
173                        RetainedBucket retainedBucket = this.buckets.get(key);
174                        if (retainedBucket != null)
175                                return retainedBucket.bucket().acquire(nowNanos, this.capacityUnits,
176                                                this.tokenUnit, this.refillTokens);
177
178                        int reclaimedSlot = -1;
179                        if (this.buckets.size() >= this.maximumRetainedPartitions)
180                                reclaimedSlot = reclaimOneFullBucket(nowNanos);
181                        if (this.buckets.size() >= this.maximumRetainedPartitions)
182                                return McpRateLimitDecision.denied(
183                                                this.configuration.getRefillInterval());
184
185                        Bucket newBucket = new Bucket(this.capacityUnits, nowNanos);
186                        McpRateLimitDecision decision = newBucket.acquire(nowNanos,
187                                        this.capacityUnits, this.tokenUnit, this.refillTokens);
188                        RetainedBucket newRetainedBucket = new RetainedBucket(key, newBucket);
189                        this.buckets.put(key, newRetainedBucket);
190                        if (reclaimedSlot < 0)
191                                this.bucketSlots.add(newRetainedBucket);
192                        else
193                                this.bucketSlots.set(reclaimedSlot, newRetainedBucket);
194                        return decision;
195                } finally {
196                        this.partitionLock.writeLock().unlock();
197                }
198        }
199
200        private int reclaimOneFullBucket(long nowNanos) {
201                int slotCount = this.bucketSlots.size();
202                int probeCount = Math.min(slotCount,
203                                this.maximumPartitionReclaimProbes);
204                for (int probe = 0; probe < probeCount; probe++) {
205                        int slot = this.nextPartitionReclaimProbeSlot;
206                        this.nextPartitionReclaimProbeSlot = slot + 1 == slotCount
207                                        ? 0 : slot + 1;
208                        RetainedBucket candidate = this.bucketSlots.get(slot);
209                        if (!candidate.bucket().isFull(nowNanos, this.capacityUnits,
210                                        this.refillTokens))
211                                continue;
212                        if (!this.buckets.remove(candidate.key(), candidate))
213                                throw new IllegalStateException(
214                                                "A retained rate-limit partition could not be reclaimed.");
215                        return slot;
216                }
217                return -1;
218        }
219
220        void reset() {
221                this.partitionLock.writeLock().lock();
222                try {
223                        this.buckets.clear();
224                        this.bucketSlots.clear();
225                        this.nextPartitionReclaimProbeSlot = 0;
226                } finally {
227                        this.partitionLock.writeLock().unlock();
228                }
229        }
230
231        int retainedPartitionCount() {
232                this.partitionLock.readLock().lock();
233                try {
234                        return this.buckets.size();
235                } finally {
236                        this.partitionLock.readLock().unlock();
237                }
238        }
239
240        /**
241         * Immutable bucket-partition identity.
242         *
243         * @param endpointPath normalized endpoint path
244         * @param partitionKey stable admission partition key
245         * @param target rate-limit stage
246         * @author <a href="https://www.revetkn.com">Mark Allen</a>
247         */
248        @ThreadSafe
249        private record BucketKey(@NonNull String endpointPath,
250                        @NonNull String partitionKey, @NonNull McpRateLimitTarget target) {
251                private BucketKey {
252                        requireNonNull(endpointPath);
253                        requireNonNull(partitionKey);
254                        requireNonNull(target);
255                }
256        }
257
258        private record RetainedBucket(@NonNull BucketKey key,
259                        @NonNull Bucket bucket) {
260                private RetainedBucket {
261                        requireNonNull(key);
262                        requireNonNull(bucket);
263                }
264        }
265
266        /**
267         * One independently synchronized mutable token bucket.
268         *
269         * @author <a href="https://www.revetkn.com">Mark Allen</a>
270         */
271        @ThreadSafe
272        private static final class Bucket {
273                @NonNull
274                private BigInteger availableUnits;
275                private long lastRefillNanos;
276
277                private Bucket(@NonNull BigInteger capacityUnits, long nowNanos) {
278                        this.availableUnits = requireNonNull(capacityUnits);
279                        this.lastRefillNanos = nowNanos;
280                }
281
282                @NonNull
283                private synchronized McpRateLimitDecision acquire(long nowNanos,
284                                @NonNull BigInteger capacityUnits,
285                                @NonNull BigInteger tokenUnit,
286                                @NonNull BigInteger refillTokens) {
287                        refill(nowNanos, capacityUnits, refillTokens);
288
289                        if (this.availableUnits.compareTo(tokenUnit) >= 0) {
290                                this.availableUnits = this.availableUnits.subtract(tokenUnit);
291                                return McpRateLimitDecision.allowed();
292                        }
293
294                        BigInteger missingUnits = tokenUnit.subtract(this.availableUnits);
295                        long retryNanos = ceilingDivide(missingUnits, refillTokens).longValueExact();
296                        return McpRateLimitDecision.denied(Duration.ofNanos(retryNanos));
297                }
298
299                private synchronized boolean isFull(long nowNanos,
300                                @NonNull BigInteger capacityUnits,
301                                @NonNull BigInteger refillTokens) {
302                        refill(nowNanos, capacityUnits, refillTokens);
303                        return this.availableUnits.equals(capacityUnits);
304                }
305
306                private void refill(long nowNanos, @NonNull BigInteger capacityUnits,
307                                @NonNull BigInteger refillTokens) {
308                        long elapsedNanos = nowNanos - this.lastRefillNanos;
309                        if (elapsedNanos <= 0)
310                                return;
311                        BigInteger replenishedUnits = BigInteger.valueOf(elapsedNanos)
312                                        .multiply(refillTokens);
313                        this.availableUnits = this.availableUnits.add(replenishedUnits)
314                                        .min(capacityUnits);
315                        this.lastRefillNanos = nowNanos;
316                }
317
318                @NonNull
319                private static BigInteger ceilingDivide(@NonNull BigInteger dividend,
320                                @NonNull BigInteger divisor) {
321                        BigInteger[] quotientAndRemainder = dividend.divideAndRemainder(divisor);
322                        return quotientAndRemainder[1].signum() == 0
323                                        ? quotientAndRemainder[0]
324                                        : quotientAndRemainder[0].add(BigInteger.ONE);
325                }
326        }
327}
328
329/**
330 * Package-private monotonic clock seam for deterministic limiter tests.
331 *
332 * @author <a href="https://www.revetkn.com">Mark Allen</a>
333 */
334@ThreadSafe
335@FunctionalInterface
336interface McpRateLimiterClock {
337        long nanoTime();
338}