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}