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}