001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.rest.server.mcp.v20260728;
018
019import java.time.*;
020
021import org.apache.juneau.commons.concurrent.*;
022
023/**
024 * Binding-owned MRTR (Multi-Round-Trip Request) configuration for a v2 {@link McpRevision}.
025 *
026 * <p>
027 * Mirrors {@link McpCacheConfig}'s exact placement precedent (spec Open item #2): MRTR is as
028 * {@code 2026-07-28}-only as cache hints, so its configuration lives here, on the v2 adapter, never on the
029 * revision-neutral {@link org.apache.juneau.rest.server.mcp.McpServerConfig}.
030 *
031 * <p>
032 * <b>Mutable during setup, effectively immutable once published.</b> This is a builder-less mutable-setup
033 * bean: its fluent setters exist to configure it during construction, but a binding must fully configure it
034 * <i>before</i> handing it to a {@link McpRevision} and must never mutate it afterward. The dispatcher reads
035 * the codec/TTL/max-rounds on every request with no synchronization, so a post-publication mutation is a data
036 * race; treat a published instance as read-only.
037 *
038 * <p>
039 * Defaults: {@link AeadRequestStateCodec} (a fresh instance per {@link McpMrtrConfig}), a 5-minute
040 * {@code requestState} TTL, a max-rounds cap of 10, no {@link ReplayCache} (replay rejection is opt-in; see
041 * {@link #setReplayCache(ReplayCache)}), and a {@link Clock#systemUTC()} clock for stamping and checking token
042 * expiry (see {@link #setClock(Clock)}). Operators inject a shared/rotating-key {@link RequestStateCodec} here
043 * for multi-instance or restart-durable resumption.
044 *
045 * <h5 class='section'>Trust model:</h5>
046 * <ul>
047 *    <li><b>{@code requestState} is a bearer token.</b> Anyone holding a sealed {@code requestState} can resume
048 *       the paused operation until it expires; the token is not bound to a caller identity. It must be
049 *       transported only over authenticated TLS and <b>never logged</b> (it is opaque ciphertext, but logging
050 *       it hands a replayable credential to anyone with log access). See {@link RequestStateCodec}.
051 *    <li><b>Resume side effects must be idempotent by default.</b> A captured token can be replayed any number
052 *       of times within its {@link #getTtlMs() TTL} <i>unless</i> a {@link ReplayCache} is configured (see
053 *       {@link #setReplayCache(ReplayCache)}) to enforce single-use &mdash; the built-in
054 *       {@link InMemoryReplayCache} is per-process only, so cross-node single-use requires a shared
055 *       implementation. The {@link #getMaxRounds() max-rounds cap} bounds the <i>depth</i> of a single resume
056 *       chain, not the total number of times a given token can be re-submitted, so a handler must not treat a
057 *       resume as a once-only event unless it knows single-use is enforced.
058 * </ul>
059 */
060public class McpMrtrConfig {
061
062   /** Default {@code requestState} TTL in milliseconds: 5 minutes. */
063   public static final long DEFAULT_TTL_MS = 5 * 60 * 1000L;
064
065   /** Default max-rounds cap. */
066   public static final int DEFAULT_MAX_ROUNDS = 10;
067
068   private RequestStateCodec codec = new AeadRequestStateCodec();
069   private long ttlMs = DEFAULT_TTL_MS;
070   private int maxRounds = DEFAULT_MAX_ROUNDS;
071   private ReplayCache replayCache;
072   private Clock clock = Clock.systemUTC();
073
074   /**
075    * The codec used to seal/unseal {@code requestState} tokens.
076    *
077    * @return The codec. Never {@code null}.
078    */
079   public RequestStateCodec getCodec() {
080      return codec;
081   }
082
083   /**
084    * Sets the codec.
085    *
086    * @param value The new value. Must not be {@code null}.
087    * @return This object.
088    * @throws IllegalArgumentException If {@code value} is {@code null}.
089    */
090   public McpMrtrConfig setCodec(RequestStateCodec value) {
091      if (value == null)
092         throw new IllegalArgumentException("codec must not be null");
093      codec = value;
094      return this;
095   }
096
097   /**
098    * Sets the key provider, wrapping it in a fresh {@link AeadRequestStateCodec}.
099    *
100    * <p>
101    * Sugar for the common "keep AES-GCM, just supply my keys" case &mdash; equivalent to
102    * {@code setCodec(new AeadRequestStateCodec(value))}. {@link #setKeyProvider} and {@link #setCodec} are
103    * last-wins: both assign the same {@code codec} field, so whichever is called more recently determines the
104    * effective codec. There is no getter for the provider itself &mdash; {@link #getCodec()} remains the sole
105    * accessor, since the codec (not the provider) is the source of truth once either setter has been called.
106    *
107    * @param value The new value. Must not be {@code null}.
108    * @return This object.
109    * @throws IllegalArgumentException If {@code value} is {@code null}.
110    */
111   public McpMrtrConfig setKeyProvider(KeyProvider value) {
112      if (value == null)
113         throw new IllegalArgumentException("keyProvider must not be null");
114      return setCodec(new AeadRequestStateCodec(value));
115   }
116
117   /**
118    * The {@code requestState} time-to-live in milliseconds.
119    *
120    * @return The TTL. Always {@code > 0}.
121    */
122   public long getTtlMs() {
123      return ttlMs;
124   }
125
126   /**
127    * Sets the TTL.
128    *
129    * @param value The new value. Must be {@code > 0}.
130    * @return This object.
131    * @throws IllegalArgumentException If {@code value} is not {@code > 0}.
132    */
133   public McpMrtrConfig setTtlMs(long value) {
134      if (value <= 0)
135         throw new IllegalArgumentException("ttlMs " + value + " must be > 0");
136      ttlMs = value;
137      return this;
138   }
139
140   /**
141    * The max-rounds cap.
142    *
143    * <p>
144    * Counts total handler invocations in a single resume chain, not just the resumes: the default of 10 permits
145    * 1 initial call plus 9 resumes before {@link McpRevision#CODE_MAX_ROUNDS_EXCEEDED} is returned.
146    *
147    * @return The cap. Always {@code >= 1}.
148    */
149   public int getMaxRounds() {
150      return maxRounds;
151   }
152
153   /**
154    * Sets the max-rounds cap.
155    *
156    * @param value The new value. Must be {@code >= 1}.
157    * @return This object.
158    * @throws IllegalArgumentException If {@code value} is not {@code >= 1}.
159    */
160   public McpMrtrConfig setMaxRounds(int value) {
161      if (value < 1)
162         throw new IllegalArgumentException("maxRounds " + value + " must be >= 1");
163      maxRounds = value;
164      return this;
165   }
166
167   /**
168    * The {@link ReplayCache} used to reject reuse of a consumed {@code requestState} token, or <jk>null</jk> if
169    * none is configured.
170    *
171    * <p>
172    * <b>Unset (<jk>null</jk>) by default &mdash; replay rejection is opt-in.</b> A dispatcher only performs a
173    * replay check when this returns non-<jk>null</jk> (see {@code McpRevision#resolveMrtrContext}); with no
174    * {@link ReplayCache} configured, a {@code requestState} token remains multi-use within its TTL, exactly as
175    * documented on {@link RequestStateCodec}.
176    *
177    * @return The configured replay cache, or <jk>null</jk> if replay rejection is disabled (the default).
178    */
179   public ReplayCache getReplayCache() {
180      return replayCache;
181   }
182
183   /**
184    * Sets the replay cache, enabling replay rejection.
185    *
186    * <p>
187    * Passing <jk>null</jk> is allowed and explicitly reverts to the default, opt-out behavior (no replay check;
188    * a token stays multi-use within its TTL) &mdash; unlike {@link #setCodec(RequestStateCodec)}, there is no
189    * always-non-null invariant to preserve here, since "no replay cache" is itself the documented default.
190    *
191    * @param value The replay cache to use, or <jk>null</jk> to disable replay rejection.
192    * @return This object.
193    */
194   public McpMrtrConfig setReplayCache(ReplayCache value) {
195      replayCache = value;
196      return this;
197   }
198
199   /**
200    * The clock used to stamp a new {@code requestState}'s expiry and to check an echoed one against it.
201    *
202    * @return The clock. Never {@code null}.
203    */
204   public Clock getClock() {
205      return clock;
206   }
207
208   /**
209    * Sets the clock.
210    *
211    * <p>
212    * Defaults to {@link Clock#systemUTC()}. Tests can supply a {@link Clock#fixed(Instant, ZoneId) fixed} or
213    * manually-advanced clock to drive {@code requestState} expiry deterministically instead of sleeping past the
214    * {@link #getTtlMs() TTL}.
215    *
216    * @param value The new value. Must not be {@code null}.
217    * @return This object.
218    * @throws IllegalArgumentException If {@code value} is {@code null}.
219    */
220   public McpMrtrConfig setClock(Clock value) {
221      if (value == null)
222         throw new IllegalArgumentException("clock must not be null");
223      clock = value;
224      return this;
225   }
226}