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 ownership and bounds for explicitly enabled MCP 031 * {@code 2025-06-18} and {@code 2025-11-25} sessions. Soklet's 032 * {@code 2026-07-28} implementation does not use this configuration. 033 * <p> 034 * Configure this value on {@link McpServer.Builder#sessionConfig(McpSessionConfig)} 035 * and select exact session revisions on each applicable endpoint through 036 * {@link McpEndpoint.Builder#sessionProtocolVersions(java.util.Set)}. 037 * Configuration alone does not enable sessions on every endpoint. Sessions 038 * are bounded, node-local state owned by the MCP server's lifecycle; session 039 * IDs are correlation handles, not credentials. Every use requires fresh 040 * admission and owner verification. Multi-node deployments require affinity to 041 * the initializing node for all session traffic. There is no application-supplied 042 * session-store hook; node loss requires client reinitialization and resubscription. 043 * Application-owned task storage, publishers, and authorization services may be 044 * distributed independently of these local sessions. 045 * <p> 046 * Capacity reclamation replaces only the initializing owner's quiescent 047 * sessions after at least 30 seconds without client activity or physically 048 * active client work. Repeated reconnects without DELETE can reach the owner 049 * limit before any record becomes eligible. There is no global fair-share reclamation: the default 256 slots 050 * can be filled by 16 owners at their 16-session limits, denying new owners 051 * until slots are released or abandoned sessions reach the 24-hour idle limit. 052 * Configure counts for the owner population and idle duration for abandoned 053 * clients. Session capacity failures do not supply an invented retry delay. 054 * Anonymous sessions, when enabled, also share a 64-session global ceiling. 055 * <p> 056 * Instances retain reference identity because the application-owned resolver 057 * is a live callback. Configuration does not make its state immutable or 058 * transfer its lifecycle to Soklet. Defaults are initial operational limits, 059 * not a recovery or capacity guarantee for a particular deployment. 060 * 061 * @author <a href="https://www.revetkn.com">Mark Allen</a> 062 */ 063@ThreadSafe 064public final class McpSessionConfig { 065 @NonNull 066 private final McpSessionOwnerKeyResolver ownerKeyResolver; 067 private final int maximumSessions; 068 private final int maximumSessionsPerOwner; 069 @NonNull 070 private final Duration maximumSessionIdleDuration; 071 @NonNull 072 private final Duration maximumSessionDuration; 073 private final int maximumClientMetadataSizeInBytes; 074 private final boolean anonymousSessionsAllowed; 075 @Nullable 076 private final McpSessionTransportAdmissionController transportAdmissionController; 077 078 /** 079 * Vends a builder primed with its required application owner resolver. 080 * 081 * @param sessionOwnerKeyResolver application-owned stable owner resolver 082 * @return a session-configuration builder 083 * @throws NullPointerException if the resolver is null 084 */ 085 @NonNull 086 public static Builder withOwnerKeyResolver( 087 @NonNull McpSessionOwnerKeyResolver sessionOwnerKeyResolver) { 088 return new Builder(sessionOwnerKeyResolver); 089 } 090 091 private McpSessionConfig(@NonNull Builder builder) { 092 requireNonNull(builder); 093 this.ownerKeyResolver = builder.ownerKeyResolver; 094 this.maximumSessions = builder.maximumSessions; 095 this.maximumSessionsPerOwner = builder.maximumSessionsPerOwner; 096 this.maximumSessionIdleDuration = builder.maximumSessionIdleDuration; 097 this.maximumSessionDuration = builder.maximumSessionDuration; 098 this.maximumClientMetadataSizeInBytes = builder.maximumClientMetadataSizeInBytes; 099 this.anonymousSessionsAllowed = builder.anonymousSessionsAllowed; 100 this.transportAdmissionController = builder.transportAdmissionController; 101 } 102 103 /** @return application-owned stable owner resolver */ 104 @NonNull 105 public McpSessionOwnerKeyResolver getOwnerKeyResolver() { 106 return this.ownerKeyResolver; 107 } 108 109 /** @return maximum live and provisional sessions across this server; default 256 */ 110 @NonNull 111 public Integer getMaximumSessions() { 112 return this.maximumSessions; 113 } 114 115 /** @return maximum sessions for one resolved owner; default 16 */ 116 @NonNull 117 public Integer getMaximumSessionsPerOwner() { 118 return this.maximumSessionsPerOwner; 119 } 120 121 /** 122 * Returns the lifetime of a quiescent session without accepted owner-bound 123 * activity. Physically active initialization, POST and GET work prevents idle 124 * expiry. Background URI authorization renewal does not refresh this lifetime 125 * or prevent logical expiry; unfinished callbacks retain their evidence until 126 * they exit. Invalid requests and transport keep-alives do not refresh this 127 * lifetime. 128 * 129 * @return positive idle lifetime; default 24 hours 130 */ 131 @NonNull 132 public Duration getMaximumSessionIdleDuration() { 133 return this.maximumSessionIdleDuration; 134 } 135 136 /** 137 * Returns the absolute session lifetime, independent of activity. 138 * 139 * @return positive hard lifetime; default 7 days 140 */ 141 @NonNull 142 public Duration getMaximumSessionDuration() { 143 return this.maximumSessionDuration; 144 } 145 146 /** 147 * Returns the maximum retained initialization client-info and capability 148 * projection size. Existing JSON byte and node limits also apply; an 149 * oversized snapshot is rejected rather than partially retained. 150 * 151 * @return maximum encoded projection size in bytes; default 65536 152 */ 153 @NonNull 154 public Integer getMaximumClientMetadataSizeInBytes() { 155 return this.maximumClientMetadataSizeInBytes; 156 } 157 158 /** 159 * Indicates whether identities for which 160 * {@link McpAdmissionIdentity#isAuthenticated()} is false may allocate sessions. 161 * Anonymous owners use separate internal namespaces and a bounded shared 162 * sub-budget in addition to the configured owner and server limits. 163 * 164 * @return whether anonymous sessions are allowed; default false 165 */ 166 @NonNull 167 public Boolean isAnonymousSessionsAllowed() { 168 return this.anonymousSessionsAllowed; 169 } 170 171 /** 172 * Returns the HTTP controller for GET delivery and DELETE cleanup on the 173 * explicitly session-enabled 2025 revisions. Absence disables both methods. 174 * A controller enables DELETE cleanup; GET additionally requires an explicit 175 * endpoint subscription revision and effective notification families. 176 * 177 * @return application-owned HTTP transport admission controller, when configured 178 */ 179 @NonNull 180 public Optional<@NonNull McpSessionTransportAdmissionController> getTransportAdmissionController() { 181 return Optional.ofNullable(this.transportAdmissionController); 182 } 183 184 /** 185 * Builder for immutable {@link McpSessionConfig} values. 186 * This class is intended for use by a single thread. Null tuning arguments 187 * restore their defaults; failed replacements leave prior values intact. 188 * 189 * @author <a href="https://www.revetkn.com">Mark Allen</a> 190 */ 191 @NotThreadSafe 192 public static final class Builder { 193 private static final int DEFAULT_MAXIMUM_SESSIONS = 256; 194 private static final int DEFAULT_MAXIMUM_SESSIONS_PER_OWNER = 16; 195 @NonNull 196 private static final Duration DEFAULT_MAXIMUM_SESSION_IDLE_DURATION = Duration.ofHours(24); 197 @NonNull 198 private static final Duration DEFAULT_MAXIMUM_SESSION_DURATION = Duration.ofDays(7); 199 private static final int DEFAULT_MAXIMUM_CLIENT_METADATA_SIZE_IN_BYTES = 64 * 1024; 200 201 @NonNull 202 private final McpSessionOwnerKeyResolver ownerKeyResolver; 203 private int maximumSessions = DEFAULT_MAXIMUM_SESSIONS; 204 private int maximumSessionsPerOwner = DEFAULT_MAXIMUM_SESSIONS_PER_OWNER; 205 @NonNull 206 private Duration maximumSessionIdleDuration = DEFAULT_MAXIMUM_SESSION_IDLE_DURATION; 207 @NonNull 208 private Duration maximumSessionDuration = DEFAULT_MAXIMUM_SESSION_DURATION; 209 private int maximumClientMetadataSizeInBytes = DEFAULT_MAXIMUM_CLIENT_METADATA_SIZE_IN_BYTES; 210 private boolean anonymousSessionsAllowed; 211 @Nullable 212 private McpSessionTransportAdmissionController transportAdmissionController; 213 214 private Builder(@NonNull McpSessionOwnerKeyResolver sessionOwnerKeyResolver) { 215 this.ownerKeyResolver = requireNonNull(sessionOwnerKeyResolver); 216 } 217 218 /** 219 * Sets the server-wide live and provisional session limit. 220 * 221 * @param maximumSessions positive limit, or null to restore 256 222 * @return this builder 223 * @throws IllegalArgumentException if the limit is not positive 224 */ 225 @NonNull 226 public Builder maximumSessions(@Nullable Integer maximumSessions) { 227 this.maximumSessions = maximumSessions == null ? DEFAULT_MAXIMUM_SESSIONS 228 : requirePositiveInteger(maximumSessions); 229 return this; 230 } 231 232 /** 233 * Sets the limit for one resolved owner; it must not exceed the server limit. 234 * 235 * @param maximumSessionsPerOwner positive limit, or null to restore 16 236 * @return this builder 237 * @throws IllegalArgumentException if the limit is not positive 238 */ 239 @NonNull 240 public Builder maximumSessionsPerOwner(@Nullable Integer maximumSessionsPerOwner) { 241 this.maximumSessionsPerOwner = maximumSessionsPerOwner == null 242 ? DEFAULT_MAXIMUM_SESSIONS_PER_OWNER : requirePositiveInteger(maximumSessionsPerOwner); 243 return this; 244 } 245 246 /** 247 * Sets the positive quiescent session lifetime, no longer than the hard lifetime. 248 * 249 * @param maximumSessionIdleDuration positive finite duration, or null to restore 24 hours 250 * @return this builder 251 * @throws IllegalArgumentException if the duration is not positive or does not fit in signed nanoseconds 252 */ 253 @NonNull 254 public Builder maximumSessionIdleDuration(@Nullable Duration maximumSessionIdleDuration) { 255 this.maximumSessionIdleDuration = maximumSessionIdleDuration == null 256 ? DEFAULT_MAXIMUM_SESSION_IDLE_DURATION : requirePositiveDuration(maximumSessionIdleDuration); 257 return this; 258 } 259 260 /** 261 * Sets the positive absolute session lifetime. 262 * 263 * @param maximumSessionDuration positive finite duration, or null to restore 7 days 264 * @return this builder 265 * @throws IllegalArgumentException if the duration is not positive or does not fit in signed nanoseconds 266 */ 267 @NonNull 268 public Builder maximumSessionDuration(@Nullable Duration maximumSessionDuration) { 269 this.maximumSessionDuration = maximumSessionDuration == null 270 ? DEFAULT_MAXIMUM_SESSION_DURATION : requirePositiveDuration(maximumSessionDuration); 271 return this; 272 } 273 274 /** 275 * Sets the encoded initialization metadata projection limit. 276 * 277 * @param maximumClientMetadataSizeInBytes positive byte limit, or null to restore 65536 278 * @return this builder 279 * @throws IllegalArgumentException if the limit is not positive 280 */ 281 @NonNull 282 public Builder maximumClientMetadataSizeInBytes(@Nullable Integer maximumClientMetadataSizeInBytes) { 283 this.maximumClientMetadataSizeInBytes = maximumClientMetadataSizeInBytes == null 284 ? DEFAULT_MAXIMUM_CLIENT_METADATA_SIZE_IN_BYTES : requirePositiveInteger(maximumClientMetadataSizeInBytes); 285 return this; 286 } 287 288 /** 289 * Explicitly permits session allocation for unauthenticated admitted identities. 290 * 291 * @param anonymousSessionsAllowed whether anonymous allocation is allowed, or null to restore false 292 * @return this builder 293 */ 294 @NonNull 295 public Builder anonymousSessionsAllowed(@Nullable Boolean anonymousSessionsAllowed) { 296 this.anonymousSessionsAllowed = anonymousSessionsAllowed != null && anonymousSessionsAllowed; 297 return this; 298 } 299 300 /** 301 * Sets fresh HTTP admission for session GET delivery and DELETE cleanup. 302 * The callback is application-owned and must support concurrent calls. 303 * Configuration alone does not enable GET notification delivery on every 304 * session endpoint. Null clears the controller and disables both methods. 305 * 306 * @param sessionTransportAdmissionController HTTP admission callback, or null to clear 307 * @return this builder 308 */ 309 @NonNull 310 public Builder transportAdmissionController( 311 @Nullable McpSessionTransportAdmissionController sessionTransportAdmissionController) { 312 this.transportAdmissionController = sessionTransportAdmissionController; 313 return this; 314 } 315 316 /** 317 * Builds an immutable configuration without invoking its owner resolver. 318 * 319 * @return session configuration 320 * @throws IllegalStateException if the owner limit exceeds the server limit or the idle lifetime exceeds the hard lifetime 321 */ 322 @NonNull 323 public McpSessionConfig build() { 324 if (this.maximumSessionsPerOwner > this.maximumSessions) 325 throw new IllegalStateException("The MCP session owner limit must not exceed the server session limit."); 326 if (this.maximumSessionIdleDuration.compareTo(this.maximumSessionDuration) > 0) 327 throw new IllegalStateException("The MCP session idle lifetime must not exceed its absolute lifetime."); 328 return new McpSessionConfig(this); 329 } 330 331 private static int requirePositiveInteger(int value) { 332 if (value < 1) 333 throw new IllegalArgumentException("MCP session limits must be positive."); 334 return value; 335 } 336 337 @NonNull 338 private static Duration requirePositiveDuration(@NonNull Duration duration) { 339 requireNonNull(duration); 340 try { 341 if (duration.isZero() || duration.isNegative() || duration.toNanos() <= 0L) 342 throw new IllegalArgumentException("MCP session lifetimes must be positive."); 343 } catch (ArithmeticException exception) { 344 throw new IllegalArgumentException("MCP session lifetimes must fit in a signed nanosecond duration.", exception); 345 } 346 return duration; 347 } 348 } 349}