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 com.soklet.converter.TypeReference; 020import com.soklet.internal.mcp.schema.McpRuntimeTypedSchemaBridge; 021import org.jspecify.annotations.NonNull; 022import org.jspecify.annotations.Nullable; 023 024import javax.annotation.concurrent.NotThreadSafe; 025import javax.annotation.concurrent.ThreadSafe; 026import java.lang.reflect.Type; 027import java.util.Collections; 028import java.util.LinkedHashMap; 029import java.util.Map; 030import java.util.Optional; 031 032import static java.util.Objects.requireNonNull; 033 034/** 035 * Immutable input responses supplied with an MCP multi-round-trip retry or 036 * task update. 037 * 038 * <p>Raw lookup preserves the exact MCP JSON value. Typed lookup uses 039 * Soklet's closed intrinsic MCP binding and is provided both to live request 040 * contexts and to application-created test fixtures. 041 * 042 * <p>For ordinary multi-round request retries, Soklet validates that each 043 * wire value belongs to the open MCP {@code InputResponse} union. A task 044 * update is different: the task manager must first correlate each key with 045 * the task's outstanding requests, then ignore unknown, consumed, 046 * superseded, or union-mismatched values. Its input-response collection can 047 * therefore contain an arbitrary bounded MCP JSON value under a key that the 048 * manager must ignore. 049 * 050 * <p>Applications must still branch explicitly on elicitation 051 * {@code accept}, {@code decline}, and {@code cancel} actions, and validate 052 * accepted form content against the exact requested schema before causing a 053 * side effect. Typed lookup converts a value; it does not establish that 054 * request-specific correlation or policy. 055 * 056 * @author <a href="https://www.revetkn.com">Mark Allen</a> 057 */ 058@ThreadSafe 059public final class McpInputResponses { 060 @NonNull 061 private static final McpInputResponses EMPTY = 062 new McpInputResponses(Map.of()); 063 @NonNull 064 private final Map<@NonNull String, @NonNull McpJsonValue> responses; 065 066 /** 067 * Returns the shared empty response collection. 068 * 069 * @return empty input responses 070 */ 071 @NonNull 072 public static McpInputResponses emptyInstance() { 073 return EMPTY; 074 } 075 076 /** 077 * Creates an application-test fixture from exact JSON responses. 078 * 079 * @param responses response keys and exact JSON values 080 * @return immutable input responses 081 * @throws NullPointerException if the map, a key, or a value is null 082 */ 083 @NonNull 084 public static McpInputResponses fromResponses( 085 @NonNull Map<@NonNull String, 086 ? extends @NonNull McpJsonValue> responses) { 087 requireNonNull(responses); 088 if (responses.isEmpty()) 089 return emptyInstance(); 090 return new McpInputResponses(responses); 091 } 092 093 /** 094 * Vends a mutable builder for an application-test fixture. 095 * 096 * @return input-response builder 097 */ 098 @NonNull 099 public static Builder builder() { 100 return new Builder(); 101 } 102 103 private McpInputResponses( 104 @NonNull Map<@NonNull String, 105 ? extends @NonNull McpJsonValue> responses) { 106 Map<String, McpJsonValue> copiedResponses = 107 new LinkedHashMap<>(responses.size()); 108 for (Map.Entry<@NonNull String, 109 ? extends @NonNull McpJsonValue> entry : responses.entrySet()) 110 copiedResponses.put(requireNonNull(entry.getKey()), 111 requireNonNull(entry.getValue())); 112 this.responses = Collections.unmodifiableMap(copiedResponses); 113 } 114 115 /** 116 * Finds an exact JSON response by its application-assigned key. 117 * 118 * @param key response key 119 * @return exact JSON response, if present 120 * @throws NullPointerException if {@code key} is null 121 */ 122 @NonNull 123 public Optional<@NonNull McpJsonValue> find(@NonNull String key) { 124 return Optional.ofNullable(this.responses.get(requireNonNull(key))); 125 } 126 127 /** 128 * Finds and converts a response with Soklet's intrinsic MCP binding. 129 * 130 * @param key response key 131 * @param type requested Java type 132 * @param <T> requested Java type 133 * @return converted response, if the key is present 134 * @throws NullPointerException if {@code key} or {@code type} is null 135 * @throws IllegalArgumentException if the type is unsupported or the 136 * response cannot be converted to it 137 */ 138 @NonNull 139 public <T> Optional<@NonNull T> find(@NonNull String key, 140 @NonNull Class<@NonNull T> type) { 141 return findConverted(key, requireNonNull(type)); 142 } 143 144 /** 145 * Finds and converts a response to a generic type with Soklet's intrinsic 146 * MCP binding. 147 * 148 * @param key response key 149 * @param type requested generic Java type 150 * @param <T> requested Java type 151 * @return converted response, if the key is present 152 * @throws NullPointerException if {@code key} or {@code type} is null 153 * @throws IllegalArgumentException if the type is unsupported or the 154 * response cannot be converted to it 155 */ 156 @NonNull 157 public <T> Optional<@NonNull T> find(@NonNull String key, 158 @NonNull TypeReference<@NonNull T> type) { 159 return findConverted(key, requireNonNull(type).getType()); 160 } 161 162 /** 163 * Returns the immutable response map in wire order. 164 * 165 * @return immutable responses 166 */ 167 @NonNull 168 public Map<@NonNull String, @NonNull McpJsonValue> asMap() { 169 return this.responses; 170 } 171 172 /** @return whether every keyed input response is structurally equal */ 173 @Override 174 public boolean equals(@Nullable Object other) { 175 return this == other 176 || other instanceof McpInputResponses responses 177 && this.responses.equals(responses.responses); 178 } 179 180 /** @return structural input-response hash code */ 181 @Override 182 public int hashCode() { 183 return this.responses.hashCode(); 184 } 185 186 @NonNull 187 private <T> Optional<@NonNull T> findConverted(@NonNull String key, 188 @NonNull Type type) { 189 McpJsonValue response = this.responses.get(requireNonNull(key)); 190 requireNonNull(type); 191 if (response == null) 192 return Optional.empty(); 193 McpRuntimeTypedSchemaBridge<T> bridge = 194 McpRuntimeTypedSchemaBridge.compileJsonValue(type); 195 return Optional.of(bridge.decode(response)); 196 } 197 198 /** 199 * Mutable builder for immutable application-test input responses. 200 * 201 * @author <a href="https://www.revetkn.com">Mark Allen</a> 202 */ 203 @NotThreadSafe 204 public static final class Builder { 205 @NonNull 206 private final Map<@NonNull String, @NonNull McpJsonValue> responses = 207 new LinkedHashMap<>(); 208 209 private Builder() { 210 } 211 212 /** 213 * Adds an exact JSON response under an application-assigned key. 214 * 215 * <p>Repeated keys are last-call-wins. 216 * 217 * @param key response key 218 * @param inputResponse exact JSON response 219 * @return this builder 220 */ 221 @NonNull 222 public Builder addResponse(@NonNull String key, 223 @NonNull McpJsonValue inputResponse) { 224 this.responses.put(requireNonNull(key), requireNonNull(inputResponse)); 225 return this; 226 } 227 228 /** 229 * Adds all supplied response entries. 230 * 231 * <p>Entries are applied in iteration order and repeated keys are 232 * last-call-wins. 233 * 234 * @param responses response entries 235 * @return this builder 236 * @throws NullPointerException if the map, a key, or a value is null 237 */ 238 @NonNull 239 public Builder addResponses( 240 @NonNull Map<@NonNull String, 241 ? extends @NonNull McpJsonValue> responses) { 242 McpInputResponses copiedResponses = 243 McpInputResponses.fromResponses(requireNonNull(responses)); 244 this.responses.putAll(copiedResponses.responses); 245 return this; 246 } 247 248 /** 249 * Builds immutable input responses. 250 * 251 * @return immutable input responses 252 */ 253 @NonNull 254 public McpInputResponses build() { 255 return McpInputResponses.fromResponses(this.responses); 256 } 257 } 258}