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}