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.util.Collections; 025import java.util.LinkedHashMap; 026import java.util.Map; 027import java.util.Objects; 028import java.util.Optional; 029 030import static java.util.Objects.requireNonNull; 031import static com.soklet.internal.mcp.protocol.McpApplicationMetadata.requireApplicationMetadata; 032 033/** 034 * Immutable MCP result indicating that an operation needs additional client 035 * input before it can complete. 036 * 037 * <p>At least one input request or request-state value is required. Input 038 * requests retain their insertion order and their keys are unique within the 039 * result. Input-required results intentionally expose no cache controls. 040 * 041 * <p>For a durable {@link McpRequestStateMode#APPLICATION_PROTECTED} 042 * continuation, an application can persist the continuation in its own 043 * repository and return only an opaque handle as application request state. 044 * The client must carry the current handle on every retry, including retries 045 * over a new connection. Soklet transports that string; it does not store, 046 * rotate, protect, authorize, or recover the referenced application record. 047 * 048 * @author <a href="https://www.revetkn.com">Mark Allen</a> 049 */ 050@ThreadSafe 051public final class McpInputRequiredResult implements McpOperationResult { 052 @NonNull 053 private final Map<@NonNull String, @NonNull McpInputRequest> inputRequests; 054 @Nullable 055 private final McpJsonValue frameworkRequestState; 056 @Nullable 057 private final String applicationRequestState; 058 @NonNull 059 private final McpJsonObject metadata; 060 061 /** 062 * Vends a builder containing its required first input request. 063 * 064 * @param key input-response correlation key 065 * @param inputRequest server-initiated request 066 * @return input-required-result builder 067 * @throws NullPointerException if an argument is null 068 */ 069 @NonNull 070 public static Builder withInputRequest(@NonNull String key, 071 @NonNull McpInputRequest inputRequest) { 072 return new Builder().addInputRequest(key, inputRequest); 073 } 074 075 /** 076 * Vends a builder containing application JSON for Soklet to protect as 077 * request state. 078 * 079 * @param frameworkRequestState application-defined JSON state 080 * @return input-required-result builder 081 * @throws NullPointerException if {@code frameworkRequestState} is null 082 */ 083 @NonNull 084 public static Builder withFrameworkRequestState( 085 @NonNull McpJsonValue frameworkRequestState) { 086 return new Builder().frameworkRequestState(frameworkRequestState); 087 } 088 089 /** 090 * Vends a builder containing opaque request state protected by the 091 * application. 092 * 093 * @param applicationRequestState nonempty opaque application-protected state 094 * @return input-required-result builder 095 * @throws NullPointerException if {@code applicationRequestState} is null 096 * @throws IllegalArgumentException if {@code applicationRequestState} is empty or contains malformed UTF-16 097 */ 098 @NonNull 099 public static Builder withApplicationRequestState( 100 @NonNull String applicationRequestState) { 101 return new Builder().applicationRequestState(applicationRequestState); 102 } 103 104 private McpInputRequiredResult(@NonNull Builder builder) { 105 this.inputRequests = Collections.unmodifiableMap( 106 new LinkedHashMap<>(builder.inputRequests)); 107 this.frameworkRequestState = builder.frameworkRequestState; 108 this.applicationRequestState = builder.applicationRequestState; 109 this.metadata = requireApplicationMetadata(builder.metadata); 110 } 111 112 /** 113 * Returns the input requests in insertion order. 114 * 115 * @return immutable input-request map 116 */ 117 @NonNull 118 public Map<@NonNull String, @NonNull McpInputRequest> getInputRequests() { 119 return this.inputRequests; 120 } 121 122 /** 123 * Returns application-defined JSON for Soklet to protect as request state. 124 * 125 * <p>This inspection value retains the supplied representation. After 126 * protection and a verified retry, numeric leaves returned by 127 * {@link McpRequestContext#getFrameworkRequestState()} use the canonical 128 * numeric form and do not retain the original decimal scale. 129 * 130 * @return framework-protected request-state value, or empty when absent 131 */ 132 @NonNull 133 public Optional<@NonNull McpJsonValue> getFrameworkRequestState() { 134 return Optional.ofNullable(this.frameworkRequestState); 135 } 136 137 /** 138 * Returns opaque request state whose protection is application-owned. 139 * 140 * <p>The value may be a handle into an application-owned durable 141 * repository. Its presence does not imply that Soklet has validated the 142 * handle's integrity, expiry, duplicate-use status, or binding to the admitted 143 * principal or authorization context. 144 * 145 * @return application-protected request-state value, or empty when absent 146 */ 147 @NonNull 148 public Optional<@NonNull String> getApplicationRequestState() { 149 return Optional.ofNullable(this.applicationRequestState); 150 } 151 152 /** 153 * Returns application-supplied protocol extension metadata. 154 * 155 * @return immutable result metadata 156 */ 157 @NonNull 158 public McpJsonObject getMetadata() { 159 return this.metadata; 160 } 161 162 /** @return whether every input-required result property is structurally equal */ 163 @Override 164 public boolean equals(@Nullable Object other) { 165 if (this == other) 166 return true; 167 if (!(other instanceof McpInputRequiredResult result)) 168 return false; 169 return this.inputRequests.equals(result.inputRequests) 170 && Objects.equals(this.frameworkRequestState, 171 result.frameworkRequestState) 172 && Objects.equals(this.applicationRequestState, 173 result.applicationRequestState) 174 && this.metadata.equals(result.metadata); 175 } 176 177 /** @return structural input-required result hash code */ 178 @Override 179 public int hashCode() { 180 return Objects.hash(this.inputRequests, this.frameworkRequestState, 181 this.applicationRequestState, this.metadata); 182 } 183 184 /** 185 * Mutable builder for immutable {@link McpInputRequiredResult} values. 186 * 187 * @author <a href="https://www.revetkn.com">Mark Allen</a> 188 */ 189 @NotThreadSafe 190 public static final class Builder { 191 @NonNull 192 private final Map<@NonNull String, @NonNull McpInputRequest> 193 inputRequests = new LinkedHashMap<>(); 194 @Nullable 195 private McpJsonValue frameworkRequestState; 196 @Nullable 197 private String applicationRequestState; 198 @NonNull 199 private McpJsonObject metadata = McpJsonObject.emptyInstance(); 200 201 private Builder() { 202 } 203 204 /** 205 * Appends one uniquely keyed input request. 206 * 207 * <p>Keys are exact server-assigned JSON object member names; empty and 208 * whitespace-only keys remain valid MCP values. Reusing a key fails 209 * without replacing the original request. 210 * 211 * @param key input-response correlation key 212 * @param inputRequest server-initiated request 213 * @return this builder 214 * @throws NullPointerException if an argument is null 215 * @throws IllegalArgumentException if the key is already present or contains malformed UTF-16 216 */ 217 @NonNull 218 public Builder addInputRequest(@NonNull String key, 219 @NonNull McpInputRequest inputRequest) { 220 McpContentValueSupport.requireWellFormedString(requireNonNull(key)); 221 requireNonNull(inputRequest); 222 if (this.inputRequests.containsKey(key)) 223 throw new IllegalArgumentException( 224 "Input-request keys must be unique within a result."); 225 this.inputRequests.put(key, inputRequest); 226 return this; 227 } 228 229 /** 230 * Supplies application JSON for Soklet to protect as opaque request 231 * state on the wire. 232 * 233 * <p>This replaces any request state supplied by an earlier builder 234 * call. 235 * 236 * <p>Protection and its configured size, round, and expiry checks run 237 * after the handler returns. Re-emission at the maximum round or after 238 * the original expiry fails as a sanitized internal error; building this 239 * value does not guarantee that it can be sealed. Application side 240 * effects are not rolled back. Returning a complete result on a valid 241 * final-round retry does not require another state emission. 242 * 243 * @param frameworkRequestState application-defined JSON state 244 * @return this builder 245 * @throws NullPointerException if {@code frameworkRequestState} is null 246 */ 247 @NonNull 248 public Builder frameworkRequestState( 249 @NonNull McpJsonValue frameworkRequestState) { 250 McpJsonValue validatedState = requireNonNull(frameworkRequestState); 251 this.frameworkRequestState = validatedState; 252 this.applicationRequestState = null; 253 return this; 254 } 255 256 /** 257 * Supplies opaque request state protected by the application. 258 * 259 * <p>This replaces any request state supplied by an earlier builder 260 * call. 261 * 262 * <p>For durable continuation, persist the state before returning the 263 * result, use an unguessable handle, bind the repository record to the 264 * admitted security context, and consume or rotate it atomically when 265 * the application's duplicate-use policy requires that behavior. The client 266 * must return the handle on each retry; Soklet provides no repository 267 * for it. 268 * 269 * @param applicationRequestState nonempty opaque application-protected state 270 * @return this builder 271 * @throws NullPointerException if {@code applicationRequestState} is null 272 * @throws IllegalArgumentException if {@code applicationRequestState} is empty or contains malformed UTF-16 273 */ 274 @NonNull 275 public Builder applicationRequestState( 276 @NonNull String applicationRequestState) { 277 String validatedState = McpContentValueSupport.requireWellFormedString(requireNonNull(applicationRequestState)); 278 if (validatedState.isEmpty()) 279 throw new IllegalArgumentException( 280 "Application request state must not be empty."); 281 this.applicationRequestState = validatedState; 282 this.frameworkRequestState = null; 283 return this; 284 } 285 286 /** 287 * Supplies protocol extension metadata. 288 * 289 * <p>This replaces metadata supplied by an earlier builder call. 290 * 291 * @param metadata immutable result metadata 292 * @return this builder 293 * @throws NullPointerException if {@code metadata} is null 294 */ 295 @NonNull 296 public Builder metadata(@NonNull McpJsonObject metadata) { 297 this.metadata = requireNonNull(metadata); 298 return this; 299 } 300 301 /** 302 * Builds an immutable input-required result. 303 * 304 * @return immutable input-required result 305 * @throws IllegalArgumentException if application metadata uses a reserved framework key 306 */ 307 @NonNull 308 public McpInputRequiredResult build() { 309 return new McpInputRequiredResult(this); 310 } 311 } 312}