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.time.Instant; 026import java.util.Collections; 027import java.util.LinkedHashMap; 028import java.util.Map; 029import java.util.Objects; 030import java.util.Optional; 031 032import static com.soklet.internal.mcp.protocol.McpApplicationMetadata.requireApplicationMetadata; 033import static java.util.Objects.requireNonNull; 034 035/** 036 * Immutable authoritative snapshot of one MCP task. 037 * 038 * <p>The configured {@link McpTaskManager} returns this value after atomically 039 * authorizing the current request. The origin is application-persisted 040 * framework data and is never rendered on the wire. Task status determines 041 * the one permitted status-specific payload: outstanding input requests for 042 * {@link McpTaskStatus#INPUT_REQUIRED}, a complete result for 043 * {@link McpTaskStatus#COMPLETED}, or a JSON-RPC failure for 044 * {@link McpTaskStatus#FAILED}. A tool result whose {@code isError} value is 045 * true is still a completed task. 046 * 047 * <p>This value does not contain task ownership or an authorization decision. 048 * A manager must independently bind the task to the admitted principal, 049 * tenant, endpoint, and other application authorization context, and must not 050 * return a snapshot to an unauthorized request. 051 * 052 * <p>Construction validates the status-specific payload shape, not whether 053 * that payload can be delivered under the origin tool's contract. Soklet 054 * checks outstanding input declarations and completed output during delivery, 055 * using the current server's access policy and result sanitizer. A failed 056 * delivery does not change this authoritative snapshot or synthesize a 057 * {@link McpTaskStatus#FAILED} state. 058 * 059 * @author <a href="https://www.revetkn.com">Mark Allen</a> 060 */ 061@ThreadSafe 062public final class McpTask { 063 @NonNull 064 private final String taskId; 065 @NonNull 066 private final McpTaskOrigin taskOrigin; 067 @NonNull 068 private final McpTaskStatus taskStatus; 069 @Nullable 070 private final String taskStatusMessage; 071 @NonNull 072 private final Instant createdAt; 073 @NonNull 074 private final Instant lastUpdatedAt; 075 @Nullable 076 private final Duration timeToLive; 077 @Nullable 078 private final Duration pollInterval; 079 @NonNull 080 private final Map<@NonNull String, @NonNull McpInputRequest> inputRequests; 081 @Nullable 082 private final McpCompleteResult completedResult; 083 @Nullable 084 private final McpJsonRpcError failure; 085 @NonNull 086 private final McpJsonObject metadata; 087 088 /** 089 * Vends a builder primed with every required task property. 090 * 091 * @param taskId nonblank task identifier without carriage-return or newline 092 * characters 093 * @param taskOrigin persisted framework origin 094 * @param taskStatus current task status 095 * @param createdAt task creation time 096 * @param lastUpdatedAt most recent state-update time, not before creation 097 * @return task builder 098 * @throws NullPointerException if an argument is null 099 * @throws IllegalArgumentException if the task ID or timestamps are invalid 100 */ 101 @NonNull 102 public static Builder withTaskId(@NonNull String taskId, 103 @NonNull McpTaskOrigin taskOrigin, 104 @NonNull McpTaskStatus taskStatus, 105 @NonNull Instant createdAt, 106 @NonNull Instant lastUpdatedAt) { 107 return new Builder(taskId, taskOrigin, taskStatus, createdAt, 108 lastUpdatedAt); 109 } 110 111 private McpTask(@NonNull Builder builder) { 112 this.taskId = requireTaskId(builder.taskId); 113 this.taskOrigin = requireNonNull(builder.taskOrigin); 114 this.taskStatus = requireNonNull(builder.taskStatus); 115 this.taskStatusMessage = builder.taskStatusMessage; 116 this.createdAt = requireNonNull(builder.createdAt); 117 this.lastUpdatedAt = requireNonNull(builder.lastUpdatedAt); 118 if (this.lastUpdatedAt.isBefore(this.createdAt)) 119 throw new IllegalArgumentException( 120 "Task last-updated time must not precede its creation time."); 121 this.timeToLive = requireWholeMilliseconds(builder.timeToLive, 122 "Task time to live"); 123 this.pollInterval = requireWholeMilliseconds(builder.pollInterval, 124 "Task poll interval"); 125 this.inputRequests = Collections.unmodifiableMap( 126 new LinkedHashMap<>(builder.inputRequests)); 127 this.completedResult = builder.completedResult; 128 this.failure = builder.failure; 129 this.metadata = requireApplicationMetadata(builder.metadata); 130 validateStatusPayload(); 131 } 132 133 /** @return nonblank durable task identifier */ 134 @NonNull 135 public String getTaskId() { 136 return this.taskId; 137 } 138 139 /** 140 * Returns the non-wire origin persisted with this task. 141 * 142 * @return framework-derived task origin 143 */ 144 @NonNull 145 public McpTaskOrigin getTaskOrigin() { 146 return this.taskOrigin; 147 } 148 149 /** @return current task status */ 150 @NonNull 151 public McpTaskStatus getTaskStatus() { 152 return this.taskStatus; 153 } 154 155 /** @return client-visible status message, if supplied */ 156 @NonNull 157 public Optional<@NonNull String> getTaskStatusMessage() { 158 return Optional.ofNullable(this.taskStatusMessage); 159 } 160 161 /** @return task creation time */ 162 @NonNull 163 public Instant getCreatedAt() { 164 return this.createdAt; 165 } 166 167 /** @return most recent task state-update time */ 168 @NonNull 169 public Instant getLastUpdatedAt() { 170 return this.lastUpdatedAt; 171 } 172 173 /** 174 * Returns the duration after creation for which the task may be retained. 175 * 176 * @return positive whole-millisecond duration, or empty for unlimited 177 */ 178 @NonNull 179 public Optional<@NonNull Duration> getTimeToLive() { 180 return Optional.ofNullable(this.timeToLive); 181 } 182 183 /** @return positive whole-millisecond suggested polling interval, if any */ 184 @NonNull 185 public Optional<@NonNull Duration> getPollInterval() { 186 return Optional.ofNullable(this.pollInterval); 187 } 188 189 /** 190 * Returns outstanding task input requests in insertion order. 191 * 192 * @return immutable input-request map; nonempty only for 193 * {@link McpTaskStatus#INPUT_REQUIRED} 194 */ 195 @NonNull 196 public Map<@NonNull String, @NonNull McpInputRequest> getInputRequests() { 197 return this.inputRequests; 198 } 199 200 /** 201 * Returns the operation result of a completed task. 202 * 203 * @return complete result only for {@link McpTaskStatus#COMPLETED} 204 */ 205 @NonNull 206 public Optional<@NonNull McpCompleteResult> getCompletedResult() { 207 return Optional.ofNullable(this.completedResult); 208 } 209 210 /** 211 * Returns the JSON-RPC error that failed task execution. 212 * 213 * @return failure only for {@link McpTaskStatus#FAILED} 214 */ 215 @NonNull 216 public Optional<@NonNull McpJsonRpcError> getFailure() { 217 return Optional.ofNullable(this.failure); 218 } 219 220 /** @return immutable task-result protocol extension metadata */ 221 @NonNull 222 public McpJsonObject getMetadata() { 223 return this.metadata; 224 } 225 226 /** @return whether every persisted task property is structurally equal */ 227 @Override 228 public boolean equals(@Nullable Object other) { 229 if (this == other) 230 return true; 231 if (!(other instanceof McpTask task)) 232 return false; 233 return this.taskId.equals(task.taskId) 234 && this.taskOrigin.equals(task.taskOrigin) 235 && this.taskStatus == task.taskStatus 236 && Objects.equals(this.taskStatusMessage, 237 task.taskStatusMessage) 238 && this.createdAt.equals(task.createdAt) 239 && this.lastUpdatedAt.equals(task.lastUpdatedAt) 240 && Objects.equals(this.timeToLive, task.timeToLive) 241 && Objects.equals(this.pollInterval, task.pollInterval) 242 && this.inputRequests.equals(task.inputRequests) 243 && Objects.equals(this.completedResult, task.completedResult) 244 && Objects.equals(this.failure, task.failure) 245 && this.metadata.equals(task.metadata); 246 } 247 248 /** @return structural persisted-task hash code */ 249 @Override 250 public int hashCode() { 251 return Objects.hash(this.taskId, this.taskOrigin, this.taskStatus, 252 this.taskStatusMessage, this.createdAt, this.lastUpdatedAt, 253 this.timeToLive, this.pollInterval, this.inputRequests, 254 this.completedResult, this.failure, this.metadata); 255 } 256 257 /** @return a diagnostic rendering that redacts task and application data */ 258 @Override 259 @NonNull 260 public String toString() { 261 return "McpTask{taskId=<redacted>, taskStatus=" + this.taskStatus + "}"; 262 } 263 264 @NonNull 265 static String requireTaskId(@NonNull String taskId) { 266 McpContentValueSupport.requireWellFormedString(taskId); 267 if (taskId.isBlank()) 268 throw new IllegalArgumentException("MCP task IDs must not be blank."); 269 if (taskId.indexOf('\r') >= 0 || taskId.indexOf('\n') >= 0) 270 throw new IllegalArgumentException( 271 "MCP task IDs must not contain carriage-return or newline characters."); 272 return taskId; 273 } 274 275 @Nullable 276 private static Duration requireWholeMilliseconds( 277 @Nullable Duration duration, @NonNull String description) { 278 if (duration == null) 279 return null; 280 requireNonNull(description); 281 if (duration.isNegative() || duration.isZero()) 282 throw new IllegalArgumentException(description + " must be positive."); 283 long milliseconds; 284 try { 285 milliseconds = duration.toMillis(); 286 } catch (ArithmeticException exception) { 287 throw new IllegalArgumentException( 288 description + " must fit in a signed 64-bit millisecond count.", 289 exception); 290 } 291 if (!duration.equals(Duration.ofMillis(milliseconds))) 292 throw new IllegalArgumentException( 293 description + " must have whole-millisecond precision."); 294 return duration; 295 } 296 297 private void validateStatusPayload() { 298 boolean hasInputRequests = !this.inputRequests.isEmpty(); 299 boolean hasCompletedResult = this.completedResult != null; 300 boolean hasFailure = this.failure != null; 301 switch (this.taskStatus) { 302 case INPUT_REQUIRED -> { 303 if (!hasInputRequests || hasCompletedResult || hasFailure) 304 throw invalidStatusPayload(); 305 } 306 case COMPLETED -> { 307 if (hasInputRequests || !hasCompletedResult || hasFailure) 308 throw invalidStatusPayload(); 309 } 310 case FAILED -> { 311 if (hasInputRequests || hasCompletedResult || !hasFailure) 312 throw invalidStatusPayload(); 313 } 314 case WORKING, CANCELED -> { 315 if (hasInputRequests || hasCompletedResult || hasFailure) 316 throw invalidStatusPayload(); 317 } 318 } 319 } 320 321 @NonNull 322 private IllegalStateException invalidStatusPayload() { 323 return new IllegalStateException( 324 "Task payload does not match task status " + this.taskStatus + "."); 325 } 326 327 /** 328 * Mutable builder for an immutable task snapshot. 329 * 330 * <p>The builder is intended for use by a single thread. Its 331 * status-specific setters do not silently change the configured status; 332 * {@link #build()} rejects every mismatched payload combination. 333 * 334 * @author <a href="https://www.revetkn.com">Mark Allen</a> 335 */ 336 @NotThreadSafe 337 public static final class Builder { 338 @NonNull 339 private final String taskId; 340 @NonNull 341 private final McpTaskOrigin taskOrigin; 342 @NonNull 343 private final McpTaskStatus taskStatus; 344 @NonNull 345 private final Instant createdAt; 346 @NonNull 347 private final Instant lastUpdatedAt; 348 @Nullable 349 private String taskStatusMessage; 350 @Nullable 351 private Duration timeToLive; 352 @Nullable 353 private Duration pollInterval; 354 @NonNull 355 private final Map<@NonNull String, @NonNull McpInputRequest> 356 inputRequests; 357 @Nullable 358 private McpCompleteResult completedResult; 359 @Nullable 360 private McpJsonRpcError failure; 361 @NonNull 362 private McpJsonObject metadata; 363 364 private Builder(@NonNull String taskId, 365 @NonNull McpTaskOrigin taskOrigin, 366 @NonNull McpTaskStatus taskStatus, 367 @NonNull Instant createdAt, 368 @NonNull Instant lastUpdatedAt) { 369 this.taskId = requireTaskId(taskId); 370 this.taskOrigin = requireNonNull(taskOrigin); 371 this.taskStatus = requireNonNull(taskStatus); 372 this.createdAt = requireNonNull(createdAt); 373 this.lastUpdatedAt = requireNonNull(lastUpdatedAt); 374 if (lastUpdatedAt.isBefore(createdAt)) 375 throw new IllegalArgumentException( 376 "Task last-updated time must not precede its creation time."); 377 this.inputRequests = new LinkedHashMap<>(); 378 this.metadata = McpJsonObject.emptyInstance(); 379 } 380 381 /** 382 * Sets or clears the client-visible status message. 383 * 384 * @param taskStatusMessage status message, or null to clear 385 * @return this builder 386 */ 387 @NonNull 388 public Builder taskStatusMessage( 389 @Nullable String taskStatusMessage) { 390 this.taskStatusMessage = taskStatusMessage == null ? null : McpContentValueSupport.requireWellFormedString(taskStatusMessage); 391 return this; 392 } 393 394 /** 395 * Sets the task time to live or selects unlimited retention. 396 * 397 * @param timeToLive positive whole-millisecond duration, or null for 398 * the wire-level unlimited value 399 * @return this builder 400 * @throws IllegalArgumentException if the duration is not positive, has 401 * sub-millisecond precision, or does not fit in a signed 64-bit 402 * millisecond count 403 */ 404 @NonNull 405 public Builder timeToLive(@Nullable Duration timeToLive) { 406 this.timeToLive = requireWholeMilliseconds(timeToLive, 407 "Task time to live"); 408 return this; 409 } 410 411 /** 412 * Sets or clears the suggested polling interval. 413 * 414 * @param pollInterval positive whole-millisecond duration, or null to 415 * omit the suggestion 416 * @return this builder 417 * @throws IllegalArgumentException if the duration is not positive, has 418 * sub-millisecond precision, or does not fit in a signed 64-bit 419 * millisecond count 420 */ 421 @NonNull 422 public Builder pollInterval(@Nullable Duration pollInterval) { 423 this.pollInterval = requireWholeMilliseconds(pollInterval, 424 "Task poll interval"); 425 return this; 426 } 427 428 /** 429 * Appends one uniquely keyed outstanding input request. 430 * 431 * @param key task-lifetime-unique input-request key 432 * @param inputRequest outstanding input request 433 * @return this builder 434 * @throws NullPointerException if an argument is null 435 * @throws IllegalArgumentException if the key is already present or contains malformed UTF-16 436 */ 437 @NonNull 438 public Builder addInputRequest(@NonNull String key, 439 @NonNull McpInputRequest inputRequest) { 440 McpContentValueSupport.requireWellFormedString(requireNonNull(key)); 441 requireNonNull(inputRequest); 442 if (this.inputRequests.putIfAbsent(key, inputRequest) != null) 443 throw new IllegalArgumentException( 444 "Task input-request keys must be unique."); 445 return this; 446 } 447 448 /** 449 * Appends outstanding input requests in iteration order. 450 * 451 * <p>The call fails without mutation if an entry is null or any key is 452 * already present in this builder. 453 * 454 * @param inputRequests input requests to append 455 * @return this builder 456 * @throws NullPointerException if the map, a key, or a value is null 457 * @throws IllegalArgumentException if a key is already present or contains malformed UTF-16 458 */ 459 @NonNull 460 public Builder addInputRequests( 461 @NonNull Map<@NonNull String, 462 ? extends @NonNull McpInputRequest> inputRequests) { 463 requireNonNull(inputRequests); 464 Map<@NonNull String, @NonNull McpInputRequest> copied = 465 new LinkedHashMap<>(); 466 for (Map.Entry<@NonNull String, 467 ? extends @NonNull McpInputRequest> entry 468 : inputRequests.entrySet()) { 469 String key = McpContentValueSupport.requireWellFormedString(requireNonNull(entry.getKey())); 470 McpInputRequest inputRequest = requireNonNull(entry.getValue()); 471 if (this.inputRequests.containsKey(key) 472 || copied.putIfAbsent(key, inputRequest) != null) 473 throw new IllegalArgumentException( 474 "Task input-request keys must be unique."); 475 } 476 this.inputRequests.putAll(copied); 477 return this; 478 } 479 480 /** 481 * Supplies the required completed operation result. 482 * 483 * @param completedResult completed operation result 484 * @return this builder 485 */ 486 @NonNull 487 public Builder completedResult( 488 @NonNull McpCompleteResult completedResult) { 489 this.completedResult = requireNonNull(completedResult); 490 return this; 491 } 492 493 /** 494 * Supplies the required task-execution JSON-RPC failure. 495 * 496 * @param failure client-visible JSON-RPC failure 497 * @return this builder 498 */ 499 @NonNull 500 public Builder failure(@NonNull McpJsonRpcError failure) { 501 this.failure = requireNonNull(failure); 502 return this; 503 } 504 505 /** 506 * Sets task-result protocol extension metadata. 507 * 508 * @param metadata immutable metadata, or null to restore the empty default 509 * @return this builder 510 * @throws IllegalArgumentException when {@link #build()} is called if the 511 * metadata uses a reserved MCP key 512 */ 513 @NonNull 514 public Builder metadata(@Nullable McpJsonObject metadata) { 515 this.metadata = metadata == null 516 ? McpJsonObject.emptyInstance() : metadata; 517 return this; 518 } 519 520 /** 521 * Builds and validates the immutable task snapshot. 522 * 523 * @return immutable task 524 * @throws IllegalStateException if the status-specific payload is absent 525 * or does not match the task status 526 */ 527 @NonNull 528 public McpTask build() { 529 return new McpTask(this); 530 } 531 } 532}