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; 020import org.jspecify.annotations.Nullable; 021 022import javax.annotation.concurrent.NotThreadSafe; 023import javax.annotation.concurrent.ThreadSafe; 024import java.time.Duration; 025import java.util.Optional; 026 027import static java.util.Objects.requireNonNull; 028 029/** 030 * Immutable server-wide limits and provider selection for framework-protected 031 * MCP request state. 032 * <p> 033 * No configuration is required until an operation declares framework-protected 034 * request state. Development-ephemeral mode is explicit and is not portable 035 * across restarts or server instances. A production keyring is an immutable 036 * initial value; building a server copies it into independent live state and 037 * exposes subsequent mutation only through {@link McpProtectionKeyringManager}. 038 * 039 * @author <a href="https://www.revetkn.com">Mark Allen</a> 040 */ 041@ThreadSafe 042public final class McpProtectionConfig { 043 private static final int DEFAULT_MAXIMUM_ENCODED_REQUEST_STATE_SIZE_IN_BYTES = 65_536; 044 private static final int DEFAULT_MAXIMUM_DECODED_REQUEST_STATE_SIZE_IN_BYTES = 49_152; 045 private static final int DEFAULT_MAXIMUM_REQUEST_STATE_ROUNDS = 10; 046 @NonNull 047 private static final Duration DEFAULT_MAXIMUM_REQUEST_STATE_LIFETIME = 048 Duration.ofMinutes(15); 049 050 @NonNull 051 private final McpProtectionMode protectionMode; 052 @Nullable 053 private final McpProtectionKeyring initialKeyring; 054 @Nullable 055 private final McpRequestStateProtector requestStateProtector; 056 private final int maximumEncodedRequestStateSizeInBytes; 057 private final int maximumDecodedRequestStateSizeInBytes; 058 @NonNull 059 private final Duration maximumRequestStateLifetime; 060 private final int maximumRequestStateRounds; 061 062 /** 063 * Vends a production configuration builder. 064 * 065 * @param keyring immutable initial production ring 066 * @return protection configuration builder 067 */ 068 @NonNull 069 public static Builder withKeyring(@NonNull McpProtectionKeyring keyring) { 070 return new Builder(McpProtectionMode.PRODUCTION_KEYRING, 071 requireNonNull(keyring), null); 072 } 073 074 /** 075 * Vends a custom-protector configuration builder. 076 * 077 * @param requestStateProtector application-owned protector 078 * @return protection configuration builder 079 */ 080 @NonNull 081 public static Builder withRequestStateProtector( 082 @NonNull McpRequestStateProtector requestStateProtector) { 083 return new Builder(McpProtectionMode.CUSTOM_PROTECTOR, null, 084 requireNonNull(requestStateProtector)); 085 } 086 087 /** 088 * Vends an explicit development-ephemeral configuration builder. 089 * 090 * @return protection configuration builder 091 */ 092 @NonNull 093 public static Builder withDevelopmentEphemeralProtection() { 094 return new Builder(McpProtectionMode.DEVELOPMENT_EPHEMERAL, null, null); 095 } 096 097 private McpProtectionConfig(@NonNull Builder builder) { 098 this.protectionMode = builder.protectionMode; 099 this.initialKeyring = builder.initialKeyring; 100 this.requestStateProtector = builder.requestStateProtector; 101 this.maximumEncodedRequestStateSizeInBytes = 102 builder.maximumEncodedRequestStateSizeInBytes; 103 this.maximumDecodedRequestStateSizeInBytes = 104 builder.maximumDecodedRequestStateSizeInBytes; 105 this.maximumRequestStateLifetime = builder.maximumRequestStateLifetime; 106 this.maximumRequestStateRounds = builder.maximumRequestStateRounds; 107 } 108 109 /** @return configured protection mode */ 110 @NonNull 111 public McpProtectionMode getProtectionMode() { 112 return this.protectionMode; 113 } 114 115 /** 116 * Returns the immutable initial ring configuration, not a server's live ring. 117 * 118 * @return initial keyring, if production protection is configured 119 */ 120 @NonNull 121 public Optional<@NonNull McpProtectionKeyring> getInitialKeyring() { 122 return Optional.ofNullable(this.initialKeyring); 123 } 124 125 /** @return custom application protector, when configured */ 126 @NonNull 127 public Optional<@NonNull McpRequestStateProtector> 128 getRequestStateProtector() { 129 return Optional.ofNullable(this.requestStateProtector); 130 } 131 132 /** @return positive maximum encoded request-state size in bytes */ 133 @NonNull 134 public Integer getMaximumEncodedRequestStateSizeInBytes() { 135 return this.maximumEncodedRequestStateSizeInBytes; 136 } 137 138 /** @return positive maximum decoded request-state size in bytes */ 139 @NonNull 140 public Integer getMaximumDecodedRequestStateSizeInBytes() { 141 return this.maximumDecodedRequestStateSizeInBytes; 142 } 143 144 /** @return positive finite maximum request-state lifetime */ 145 @NonNull 146 public Duration getMaximumRequestStateLifetime() { 147 return this.maximumRequestStateLifetime; 148 } 149 150 /** @return positive maximum request-state round count */ 151 @NonNull 152 public Integer getMaximumRequestStateRounds() { 153 return this.maximumRequestStateRounds; 154 } 155 156 /** 157 * Single-threaded builder for immutable request-state protection settings. 158 * 159 * @author <a href="https://www.revetkn.com">Mark Allen</a> 160 */ 161 @NotThreadSafe 162 public static final class Builder { 163 @NonNull 164 private final McpProtectionMode protectionMode; 165 @Nullable 166 private final McpProtectionKeyring initialKeyring; 167 @Nullable 168 private final McpRequestStateProtector requestStateProtector; 169 private int maximumEncodedRequestStateSizeInBytes; 170 private int maximumDecodedRequestStateSizeInBytes; 171 @NonNull 172 private Duration maximumRequestStateLifetime; 173 private int maximumRequestStateRounds; 174 175 private Builder(@NonNull McpProtectionMode protectionMode, 176 @Nullable McpProtectionKeyring initialKeyring, 177 @Nullable McpRequestStateProtector requestStateProtector) { 178 this.protectionMode = requireNonNull(protectionMode); 179 this.initialKeyring = initialKeyring; 180 this.requestStateProtector = requestStateProtector; 181 this.maximumEncodedRequestStateSizeInBytes = 182 DEFAULT_MAXIMUM_ENCODED_REQUEST_STATE_SIZE_IN_BYTES; 183 this.maximumDecodedRequestStateSizeInBytes = 184 DEFAULT_MAXIMUM_DECODED_REQUEST_STATE_SIZE_IN_BYTES; 185 this.maximumRequestStateLifetime = 186 DEFAULT_MAXIMUM_REQUEST_STATE_LIFETIME; 187 this.maximumRequestStateRounds = 188 DEFAULT_MAXIMUM_REQUEST_STATE_ROUNDS; 189 } 190 191 /** 192 * Sets the maximum encoded request-state size. The default is 65,536 bytes. 193 * <p> 194 * This is a rejection ceiling, not a guaranteed continuation capacity. 195 * Built-in protection also needs envelope and encoding overhead; custom 196 * protectors may use a different amount. A particular continuation can 197 * exceed a valid configured limit when it is sealed. 198 * 199 * @param maximumEncodedRequestStateSizeInBytes positive byte limit, or 200 * {@code null} to restore the 201 * default 202 * @return this builder 203 */ 204 @NonNull 205 public Builder maximumEncodedRequestStateSizeInBytes( 206 @Nullable Integer maximumEncodedRequestStateSizeInBytes) { 207 this.maximumEncodedRequestStateSizeInBytes = 208 maximumEncodedRequestStateSizeInBytes == null 209 ? DEFAULT_MAXIMUM_ENCODED_REQUEST_STATE_SIZE_IN_BYTES 210 : requirePositive(maximumEncodedRequestStateSizeInBytes, 211 "Maximum encoded request-state bytes"); 212 return this; 213 } 214 215 /** 216 * Sets the maximum decoded request-state size. The default is 49,152 bytes. 217 * <p> 218 * Canonical framework metadata and application state both count toward 219 * this rejection ceiling. Builder validation cannot guarantee that a 220 * particular continuation will fit. 221 * 222 * @param maximumDecodedRequestStateSizeInBytes positive byte limit, or 223 * {@code null} to restore the 224 * default 225 * @return this builder 226 */ 227 @NonNull 228 public Builder maximumDecodedRequestStateSizeInBytes( 229 @Nullable Integer maximumDecodedRequestStateSizeInBytes) { 230 this.maximumDecodedRequestStateSizeInBytes = 231 maximumDecodedRequestStateSizeInBytes == null 232 ? DEFAULT_MAXIMUM_DECODED_REQUEST_STATE_SIZE_IN_BYTES 233 : requirePositive(maximumDecodedRequestStateSizeInBytes, 234 "Maximum decoded request-state bytes"); 235 return this; 236 } 237 238 /** 239 * Sets the maximum request-state lifetime. The default is 15 minutes. 240 * 241 * <p>The first emission starts the lifetime. Later emissions preserve 242 * the original expiry. If state expires while a valid retry's handler 243 * runs, returning more framework state fails at sealing after the 244 * handler returns; it does not roll back application side effects. 245 * 246 * @param maximumRequestStateLifetime positive finite lifetime, or 247 * {@code null} to restore the default 248 * @return this builder 249 */ 250 @NonNull 251 public Builder maximumRequestStateLifetime( 252 @Nullable Duration maximumRequestStateLifetime) { 253 this.maximumRequestStateLifetime = maximumRequestStateLifetime == null 254 ? DEFAULT_MAXIMUM_REQUEST_STATE_LIFETIME 255 : requirePositiveDuration(maximumRequestStateLifetime, 256 "Maximum request-state lifetime"); 257 return this; 258 } 259 260 /** 261 * Sets the maximum request-state round count. The default is {@code 10}. 262 * 263 * <p>This counts emitted continuations, starting at one, rather than 264 * handler invocations. A valid retry carrying the final allowed round 265 * may complete, but returning further framework state fails at sealing 266 * after the handler returns. The framework's round is not exposed by 267 * {@link McpRequestContext#getFrameworkRequestState()}. 268 * 269 * @param maximumRequestStateRounds positive round limit, or {@code null} 270 * to restore the default 271 * @return this builder 272 */ 273 @NonNull 274 public Builder maximumRequestStateRounds( 275 @Nullable Integer maximumRequestStateRounds) { 276 this.maximumRequestStateRounds = maximumRequestStateRounds == null 277 ? DEFAULT_MAXIMUM_REQUEST_STATE_ROUNDS 278 : requirePositive(maximumRequestStateRounds, 279 "Maximum request-state rounds"); 280 return this; 281 } 282 283 /** 284 * Builds the immutable protection configuration. 285 * 286 * @return protection configuration 287 * @throws IllegalStateException if the decoded-size limit exceeds the 288 * encoded-size limit 289 */ 290 @NonNull 291 public McpProtectionConfig build() { 292 if (this.maximumDecodedRequestStateSizeInBytes 293 > this.maximumEncodedRequestStateSizeInBytes) 294 throw new IllegalStateException( 295 "Decoded request-state limit must not exceed encoded request-state limit."); 296 return new McpProtectionConfig(this); 297 } 298 299 private static int requirePositive(int value, 300 @NonNull String description) { 301 if (value < 1) 302 throw new IllegalArgumentException( 303 description + " must be positive."); 304 return value; 305 } 306 307 @NonNull 308 private static Duration requirePositiveDuration(@NonNull Duration value, 309 @NonNull String description) { 310 requireNonNull(value); 311 if (value.isZero() || value.isNegative()) 312 throw new IllegalArgumentException( 313 description + " must be positive."); 314 try { 315 if (value.toNanos() < 1L) 316 throw new IllegalArgumentException( 317 description + " must be positive."); 318 } catch (ArithmeticException exception) { 319 throw new IllegalArgumentException(description 320 + " must fit in a signed nanosecond duration.", exception); 321 } 322 return value; 323 } 324 } 325}