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 — 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 — 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 — {@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 — 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) — 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}