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.GuardedBy; 023import javax.annotation.concurrent.NotThreadSafe; 024import javax.annotation.concurrent.ThreadSafe; 025import java.time.Clock; 026import java.time.Duration; 027import java.time.Instant; 028import java.util.HashMap; 029import java.util.HashSet; 030import java.util.Iterator; 031import java.util.LinkedHashMap; 032import java.util.LinkedHashSet; 033import java.util.Map; 034import java.util.Optional; 035import java.util.Set; 036import java.util.UUID; 037import java.util.concurrent.locks.ReentrantLock; 038import java.util.function.LongSupplier; 039 040import static java.util.Objects.requireNonNull; 041 042/** 043 * Thread-safe, task-count-bounded, process-local manager for development and 044 * tests. 045 * <p> 046 * State exists only in this manager instance and is lost when its JVM exits. 047 * The manager provides no cross-process coordination, durable persistence, 048 * work queue, worker, lease, fencing, retry, or crash-recovery facility. It is 049 * therefore not a production task backend. Applications remain responsible 050 * for executing work and explicitly advancing task state through this class. 051 * <p> 052 * Retention is bounded by task count. Task values remain subject to Soklet's 053 * ordinary MCP request, JSON, and response limits when they cross the protocol 054 * boundary; this development helper does not attempt to estimate their JVM 055 * object sizes. 056 * <p> 057 * Worker mutations establish status-specific payload shape, not delivery 058 * validity under the origin tool's output schema and input declarations. 059 * Soklet performs those checks when reading or projecting a task. A failed 060 * delivery does not change authoritative state or notify the worker. 061 * <p> 062 * The manager never creates a thread and requires no close operation. Expired 063 * tasks are removed opportunistically during later manager operations. It 064 * never evicts an unexpired task to make room for another task. 065 * 066 * @author <a href="https://www.revetkn.com">Mark Allen</a> 067 */ 068@ThreadSafe 069public final class McpInMemoryTaskManager implements McpTaskManager { 070 private static final int DEFAULT_MAXIMUM_RETAINED_TASKS = 1_024; 071 @NonNull 072 private static final Duration DEFAULT_TASK_TIME_TO_LIVE = 073 Duration.ofHours(1); 074 @NonNull 075 private static final Duration DEFAULT_POLL_INTERVAL = Duration.ofSeconds(1); 076 077 private final int maximumRetainedTasks; 078 @NonNull 079 private final Duration taskTimeToLive; 080 private final long taskTimeToLiveNanos; 081 @NonNull 082 private final Duration pollInterval; 083 @NonNull 084 private final Clock clock; 085 @NonNull 086 private final LongSupplier nanoTime; 087 @NonNull 088 private final McpTaskEventPublisher taskEventPublisher; 089 @NonNull 090 private final ReentrantLock lock; 091 @GuardedBy("lock") 092 @NonNull 093 private final Map<@NonNull String, @NonNull Entry> entries; 094 095 /** 096 * Vends a builder initialized with finite development defaults. 097 * 098 * @return in-memory task-manager builder 099 */ 100 @NonNull 101 public static Builder builder() { 102 return new Builder(); 103 } 104 105 McpInMemoryTaskManager() { 106 this(builder()); 107 } 108 109 private McpInMemoryTaskManager(@NonNull Builder builder) { 110 this(builder, Clock.systemUTC(), System::nanoTime); 111 } 112 113 McpInMemoryTaskManager(@NonNull Builder builder, @NonNull Clock clock, 114 @NonNull LongSupplier nanoTime) { 115 requireNonNull(builder); 116 this.maximumRetainedTasks = builder.maximumRetainedTasks; 117 this.taskTimeToLive = builder.taskTimeToLive; 118 this.taskTimeToLiveNanos = this.taskTimeToLive.toNanos(); 119 this.pollInterval = builder.pollInterval; 120 this.clock = requireNonNull(clock); 121 this.nanoTime = requireNonNull(nanoTime); 122 this.taskEventPublisher = McpTaskEventPublisher.fromInMemoryDefaults(); 123 this.lock = new ReentrantLock(); 124 this.entries = new HashMap<>(); 125 } 126 127 /** @return positive maximum number of simultaneously retained tasks */ 128 @NonNull 129 public Integer getMaximumRetainedTasks() { 130 return this.maximumRetainedTasks; 131 } 132 133 /** @return finite lifetime of every task, measured from its creation */ 134 @NonNull 135 public Duration getTaskTimeToLive() { 136 return this.taskTimeToLive; 137 } 138 139 /** @return positive polling interval advertised for every task */ 140 @NonNull 141 public Duration getPollInterval() { 142 return this.pollInterval; 143 } 144 145 /** 146 * Returns this manager's process-local task-event publisher. 147 * 148 * @return task-event publisher 149 */ 150 @Override 151 @NonNull 152 public Optional<@NonNull McpTaskEventPublisher> getTaskEventPublisher() { 153 return Optional.of(this.taskEventPublisher); 154 } 155 156 /** 157 * Creates a working task for the current task-capable tool invocation. 158 * <p> 159 * The task creation context supplies Soklet's immutable task origin and the current 160 * endpoint and authorization partition. The manager copies those values and 161 * never retains the task creation context, request context, principal, or application 162 * context. The returned task is immediately available through both trusted 163 * and appropriately authorized protocol lookup. 164 * 165 * @param taskCreationContext current task creation context 166 * @return newly created working task 167 * @throws NullPointerException if the task creation context or either of its required 168 * values is null 169 * @throws IllegalStateException if the finite task capacity is exhausted 170 */ 171 @NonNull 172 public McpTask createTask(@NonNull McpTaskCreationContext taskCreationContext) { 173 requireNonNull(taskCreationContext); 174 McpRequestContext requestContext = requireNonNull( 175 taskCreationContext.getRequestContext(), 176 "taskCreationContext.getRequestContext()"); 177 McpTaskOrigin taskOrigin = requireNonNull(taskCreationContext.getTaskOrigin(), 178 "taskCreationContext.getTaskOrigin()"); 179 String endpointPath = requestContext.getEndpoint().getPath(); 180 Optional<String> authorizationPartitionKey = requestContext 181 .getAdmissionIdentity().getAuthorizationPartitionKey(); 182 183 McpTask task; 184 this.lock.lock(); 185 try { 186 long nowNanos = this.nanoTime.getAsLong(); 187 removeExpiredEntries(nowNanos); 188 if (this.entries.size() >= this.maximumRetainedTasks) 189 throw new IllegalStateException( 190 "The in-memory MCP task-manager capacity is exhausted."); 191 192 String taskId; 193 do { 194 taskId = UUID.randomUUID().toString(); 195 } while (this.entries.containsKey(taskId)); 196 197 Instant now = this.clock.instant(); 198 task = McpTask.withTaskId(taskId, taskOrigin, 199 McpTaskStatus.WORKING, now, now) 200 .timeToLive(this.taskTimeToLive) 201 .pollInterval(this.pollInterval) 202 .build(); 203 this.entries.put(taskId, new Entry(task, endpointPath, 204 authorizationPartitionKey, nowNanos)); 205 } finally { 206 this.lock.unlock(); 207 } 208 publishTaskChanged(task.getTaskId()); 209 return task; 210 } 211 212 /** 213 * Finds a task for trusted application or worker code. 214 * <p> 215 * This overload deliberately does not perform protocol authorization. Use of 216 * a task ID through an MCP request always goes through the context-taking SPI 217 * method, which does perform authorization. 218 * 219 * @param taskId task identifier 220 * @return immutable current task, if retained 221 */ 222 @NonNull 223 public Optional<@NonNull McpTask> findTask(@NonNull String taskId) { 224 String requiredTaskId = McpTask.requireTaskId(taskId); 225 this.lock.lock(); 226 try { 227 removeExpiredEntries(this.nanoTime.getAsLong()); 228 Entry entry = this.entries.get(requiredTaskId); 229 return entry == null ? Optional.empty() : Optional.of(entry.task); 230 } finally { 231 this.lock.unlock(); 232 } 233 } 234 235 /** 236 * Marks a retained nonterminal task as working. Outstanding input requests 237 * are superseded; later responses for their keys are ignored. 238 * 239 * @param taskId task identifier 240 * @param taskStatusMessage optional client-visible status message 241 * @return immutable updated task 242 * @throws McpTaskNotFoundException if the task is absent or expired 243 * @throws IllegalStateException if the task is terminal 244 */ 245 @NonNull 246 public McpTask markTaskWorking(@NonNull String taskId, 247 @Nullable String taskStatusMessage) throws McpTaskNotFoundException { 248 return transitionTask(taskId, McpTaskStatus.WORKING, 249 taskStatusMessage, Map.of(), null, null); 250 } 251 252 /** 253 * Places a retained nonterminal task into the input-required state. 254 * <p> 255 * New request keys must not have been used previously during the task's 256 * lifetime. Repeating an identical, still-outstanding key and request is an 257 * idempotent no-op; every other reuse is rejected. Accepted client responses 258 * must be taken before another input round begins. 259 * <p> 260 * This mutation does not validate requests against the originating tool's 261 * input declarations or a later caller's capabilities. An undeclared stored 262 * request fails the subsequent protocol read with a fixed internal error; 263 * a polling client lacking an outstanding request's capability receives the 264 * missing-capability protocol error. Both leave state unchanged. Trusted 265 * worker code may supersede outstanding requests 266 * through {@link #markTaskWorking(String, String)} before a corrected round. 267 * 268 * @param taskId task identifier 269 * @param inputRequests nonempty input requests to add 270 * @param taskStatusMessage optional client-visible status message 271 * @return immutable updated task 272 * @throws McpTaskNotFoundException if the task is absent or expired 273 * @throws IllegalStateException if the task is terminal or accepted input 274 * remains untaken 275 * @throws IllegalArgumentException if the map is empty or reuses a key 276 */ 277 @NonNull 278 public McpTask requestTaskInput(@NonNull String taskId, 279 @NonNull Map<@NonNull String, 280 ? extends @NonNull McpInputRequest> inputRequests, 281 @Nullable String taskStatusMessage) throws McpTaskNotFoundException { 282 requireNonNull(inputRequests); 283 if (inputRequests.isEmpty()) 284 throw new IllegalArgumentException("inputRequests must not be empty"); 285 String requiredTaskId = McpTask.requireTaskId(taskId); 286 McpTask result; 287 boolean changed; 288 289 this.lock.lock(); 290 try { 291 Entry entry = requireEntry(requiredTaskId, 292 this.nanoTime.getAsLong()); 293 requireNonterminal(entry.task); 294 if (!entry.pendingInputResponses.isEmpty()) 295 throw new IllegalStateException( 296 "Accepted MCP task input responses must be taken before requesting more input."); 297 298 Map<String, McpInputRequest> combinedInputRequests = 299 new LinkedHashMap<>(entry.task.getInputRequests()); 300 Set<String> newlyIssuedKeys = new LinkedHashSet<>(); 301 for (Map.Entry<@NonNull String, 302 ? extends @NonNull McpInputRequest> inputRequestEntry 303 : inputRequests.entrySet()) { 304 String key = requireInputRequestKey(inputRequestEntry.getKey()); 305 McpInputRequest inputRequest = requireNonNull( 306 inputRequestEntry.getValue()); 307 McpInputRequest outstanding = combinedInputRequests.get(key); 308 if (outstanding != null) { 309 if (!outstanding.equals(inputRequest)) 310 throw reusedInputRequestKey(key); 311 continue; 312 } 313 if (entry.issuedInputRequestKeys.contains(key)) 314 throw reusedInputRequestKey(key); 315 combinedInputRequests.put(key, inputRequest); 316 newlyIssuedKeys.add(key); 317 } 318 if (newlyIssuedKeys.isEmpty() 319 && entry.task.getTaskStatus() == McpTaskStatus.INPUT_REQUIRED 320 && entry.task.getTaskStatusMessage().equals( 321 Optional.ofNullable(taskStatusMessage))) { 322 result = entry.task; 323 changed = false; 324 } else { 325 McpTask updatedTask = buildTask(entry, 326 McpTaskStatus.INPUT_REQUIRED, taskStatusMessage, 327 combinedInputRequests, null, null); 328 entry.issuedInputRequestKeys.addAll(newlyIssuedKeys); 329 entry.task = updatedTask; 330 result = updatedTask; 331 changed = true; 332 } 333 } finally { 334 this.lock.unlock(); 335 } 336 if (changed) 337 publishTaskChanged(requiredTaskId); 338 return result; 339 } 340 341 /** 342 * Completes a retained nonterminal task. A tool result whose 343 * {@code isError} value is true is still a completed task result. 344 * <p> 345 * Completion does not prevalidate the originating tool's output schema, 346 * transport limits or a serving node's current sanitizer. Those checks occur 347 * during delivery. A failed read does not revert completion or synthesize a 348 * failed task, and terminal state cannot be overwritten through this manager. 349 * Applications must produce valid output before completing their work. 350 * 351 * @param taskId task identifier 352 * @param completeResult complete MCP result 353 * @param taskStatusMessage optional client-visible status message 354 * @return immutable completed task 355 * @throws McpTaskNotFoundException if the task is absent or expired 356 * @throws IllegalStateException if the task is terminal 357 */ 358 @NonNull 359 public McpTask completeTask(@NonNull String taskId, 360 @NonNull McpCompleteResult completeResult, 361 @Nullable String taskStatusMessage) throws McpTaskNotFoundException { 362 return transitionTask(taskId, McpTaskStatus.COMPLETED, 363 taskStatusMessage, Map.of(), requireNonNull(completeResult), null); 364 } 365 366 /** 367 * Fails a retained nonterminal task with a client-visible JSON-RPC error. 368 * 369 * @param taskId task identifier 370 * @param failure task failure 371 * @param taskStatusMessage optional client-visible status message 372 * @return immutable failed task 373 * @throws McpTaskNotFoundException if the task is absent or expired 374 * @throws IllegalStateException if the task is terminal 375 */ 376 @NonNull 377 public McpTask failTask(@NonNull String taskId, 378 @NonNull McpJsonRpcError failure, 379 @Nullable String taskStatusMessage) throws McpTaskNotFoundException { 380 return transitionTask(taskId, McpTaskStatus.FAILED, 381 taskStatusMessage, Map.of(), null, requireNonNull(failure)); 382 } 383 384 /** 385 * Marks a retained nonterminal task as canceled. This worker-facing state 386 * transition is separate from the protocol's cooperative cancelation request. 387 * 388 * @param taskId task identifier 389 * @param taskStatusMessage optional client-visible status message 390 * @return immutable canceled task 391 * @throws McpTaskNotFoundException if the task is absent or expired 392 * @throws IllegalStateException if the task is terminal 393 */ 394 @NonNull 395 public McpTask cancelTask(@NonNull String taskId, 396 @Nullable String taskStatusMessage) throws McpTaskNotFoundException { 397 return transitionTask(taskId, McpTaskStatus.CANCELED, 398 taskStatusMessage, Map.of(), null, null); 399 } 400 401 /** 402 * Atomically takes and clears input responses accepted for a retained task. 403 * This trusted worker operation is nonblocking. 404 * 405 * @param taskId task identifier 406 * @return accepted responses, possibly empty 407 * @throws McpTaskNotFoundException if the task is absent or expired 408 */ 409 @NonNull 410 public McpInputResponses takeTaskInputResponses(@NonNull String taskId) 411 throws McpTaskNotFoundException { 412 String requiredTaskId = McpTask.requireTaskId(taskId); 413 this.lock.lock(); 414 try { 415 Entry entry = requireEntry(requiredTaskId, 416 this.nanoTime.getAsLong()); 417 McpInputResponses inputResponses = McpInputResponses.fromResponses( 418 entry.pendingInputResponses); 419 entry.pendingInputResponses.clear(); 420 return inputResponses; 421 } finally { 422 this.lock.unlock(); 423 } 424 } 425 426 /** 427 * Returns whether a client has requested cancelation of a retained task. 428 * The signal never changes task status by itself. 429 * 430 * @param taskId task identifier 431 * @return whether cancelation was requested 432 * @throws McpTaskNotFoundException if the task is absent or expired 433 */ 434 @NonNull 435 public Boolean isTaskCancelationRequested(@NonNull String taskId) 436 throws McpTaskNotFoundException { 437 String requiredTaskId = McpTask.requireTaskId(taskId); 438 this.lock.lock(); 439 try { 440 return requireEntry(requiredTaskId, 441 this.nanoTime.getAsLong()).cancelationRequested; 442 } finally { 443 this.lock.unlock(); 444 } 445 } 446 447 /** 448 * Finds the task visible to the requesting MCP authorization partition and 449 * endpoint. An unknown task and a task owned by another partition or 450 * endpoint are deliberately indistinguishable. 451 * 452 * @param taskRequestContext protocol task lookup context 453 * @return immutable current task, if retained and authorized 454 */ 455 @Override 456 @NonNull 457 public Optional<@NonNull McpTask> findTask( 458 @NonNull McpTaskRequestContext taskRequestContext) { 459 requireNonNull(taskRequestContext); 460 this.lock.lock(); 461 try { 462 removeExpiredEntries(this.nanoTime.getAsLong()); 463 Entry entry = this.entries.get(taskRequestContext.getTaskId()); 464 if (entry == null || !isAuthorized(entry, 465 taskRequestContext.getRequestContext())) 466 return Optional.empty(); 467 return Optional.of(entry.task); 468 } finally { 469 this.lock.unlock(); 470 } 471 } 472 473 /** 474 * Accepts responses for currently outstanding input requests visible to the 475 * requesting MCP authorization partition and endpoint. Unknown or already 476 * consumed response keys and responses whose union variant does not match the 477 * outstanding request are ignored. 478 * 479 * @param taskUpdateContext protocol task update context 480 * @throws McpTaskNotFoundException if the task is absent, expired, or not 481 * visible to this request 482 */ 483 @Override 484 public void updateTask(@NonNull McpTaskUpdateContext taskUpdateContext) 485 throws McpTaskNotFoundException { 486 requireNonNull(taskUpdateContext); 487 boolean changed = false; 488 this.lock.lock(); 489 try { 490 Entry entry = requireAuthorizedEntry(taskUpdateContext.getTaskId(), 491 taskUpdateContext.getRequestContext(), this.nanoTime.getAsLong()); 492 if (isTerminal(entry.task.getTaskStatus()) 493 || entry.task.getTaskStatus() != McpTaskStatus.INPUT_REQUIRED) 494 return; 495 496 Map<String, McpInputRequest> remainingInputRequests = 497 new LinkedHashMap<>(entry.task.getInputRequests()); 498 Map<String, McpJsonValue> acceptedInputResponses = 499 new LinkedHashMap<>(); 500 for (Map.Entry<@NonNull String, @NonNull McpJsonValue> response 501 : taskUpdateContext.getInputResponses().asMap().entrySet()) { 502 McpInputRequest inputRequest = remainingInputRequests.get( 503 response.getKey()); 504 if (inputRequest != null && inputRequest.matchesInputResponse( 505 response.getValue())) { 506 remainingInputRequests.remove(response.getKey()); 507 acceptedInputResponses.put(response.getKey(), 508 response.getValue()); 509 } 510 } 511 if (acceptedInputResponses.isEmpty()) 512 return; 513 514 McpTaskStatus status = remainingInputRequests.isEmpty() 515 ? McpTaskStatus.WORKING : McpTaskStatus.INPUT_REQUIRED; 516 String taskStatusMessage = status == McpTaskStatus.INPUT_REQUIRED 517 ? entry.task.getTaskStatusMessage().orElse(null) : null; 518 McpTask updatedTask = buildTask(entry, status, taskStatusMessage, 519 remainingInputRequests, null, null); 520 entry.pendingInputResponses.putAll(acceptedInputResponses); 521 entry.task = updatedTask; 522 changed = true; 523 } finally { 524 this.lock.unlock(); 525 } 526 if (changed) 527 publishTaskChanged(taskUpdateContext.getTaskId()); 528 } 529 530 /** 531 * Records a cooperative cancelation request for a nonterminal task visible 532 * to the requesting MCP authorization partition and endpoint. This signal 533 * does not itself change the task's status. 534 * 535 * @param taskRequestContext protocol task cancelation context 536 * @throws McpTaskNotFoundException if the task is absent, expired, or not 537 * visible to this request 538 */ 539 @Override 540 public void requestTaskCancelation( 541 @NonNull McpTaskRequestContext taskRequestContext) 542 throws McpTaskNotFoundException { 543 requireNonNull(taskRequestContext); 544 this.lock.lock(); 545 try { 546 Entry entry = requireAuthorizedEntry(taskRequestContext.getTaskId(), 547 taskRequestContext.getRequestContext(), this.nanoTime.getAsLong()); 548 if (!isTerminal(entry.task.getTaskStatus())) 549 entry.cancelationRequested = true; 550 } finally { 551 this.lock.unlock(); 552 } 553 } 554 555 @NonNull 556 private McpTask transitionTask(@NonNull String taskId, 557 @NonNull McpTaskStatus taskStatus, 558 @Nullable String taskStatusMessage, 559 @NonNull Map<@NonNull String, 560 ? extends @NonNull McpInputRequest> inputRequests, 561 @Nullable McpCompleteResult completeResult, 562 @Nullable McpJsonRpcError failure) 563 throws McpTaskNotFoundException { 564 String requiredTaskId = McpTask.requireTaskId(taskId); 565 McpTask updatedTask; 566 this.lock.lock(); 567 try { 568 Entry entry = requireEntry(requiredTaskId, 569 this.nanoTime.getAsLong()); 570 requireNonterminal(entry.task); 571 updatedTask = buildTask(entry, taskStatus, 572 taskStatusMessage, inputRequests, completeResult, failure); 573 entry.task = updatedTask; 574 if (isTerminal(taskStatus)) { 575 entry.pendingInputResponses.clear(); 576 } 577 } finally { 578 this.lock.unlock(); 579 } 580 publishTaskChanged(requiredTaskId); 581 return updatedTask; 582 } 583 584 private void publishTaskChanged(@NonNull String taskId) { 585 try { 586 this.taskEventPublisher.publishTaskChanged(requireNonNull(taskId)); 587 } catch (RuntimeException ignored) { 588 // Optional notification delivery cannot make an already-committed task 589 // mutation appear to have failed. Polling remains authoritative. 590 } 591 } 592 593 @NonNull 594 private McpTask buildTask(@NonNull Entry entry, 595 @NonNull McpTaskStatus taskStatus, 596 @Nullable String taskStatusMessage, 597 @NonNull Map<@NonNull String, 598 ? extends @NonNull McpInputRequest> inputRequests, 599 @Nullable McpCompleteResult completeResult, 600 @Nullable McpJsonRpcError failure) { 601 Instant now = this.clock.instant(); 602 if (now.isBefore(entry.task.getLastUpdatedAt())) 603 now = entry.task.getLastUpdatedAt(); 604 McpTask.Builder builder = McpTask.withTaskId(entry.task.getTaskId(), 605 entry.task.getTaskOrigin(), taskStatus, 606 entry.task.getCreatedAt(), now) 607 .taskStatusMessage(taskStatusMessage) 608 .timeToLive(this.taskTimeToLive) 609 .pollInterval(this.pollInterval) 610 .metadata(entry.task.getMetadata()); 611 if (!inputRequests.isEmpty()) 612 builder.addInputRequests(inputRequests); 613 if (completeResult != null) 614 builder.completedResult(completeResult); 615 if (failure != null) 616 builder.failure(failure); 617 return builder.build(); 618 } 619 620 @NonNull 621 @GuardedBy("lock") 622 private Entry requireAuthorizedEntry(@NonNull String taskId, 623 @NonNull McpRequestContext requestContext, long nowNanos) 624 throws McpTaskNotFoundException { 625 Entry entry = requireEntry(McpTask.requireTaskId(taskId), nowNanos); 626 if (!isAuthorized(entry, requestContext)) 627 throw new McpTaskNotFoundException(); 628 return entry; 629 } 630 631 @NonNull 632 @GuardedBy("lock") 633 private Entry requireEntry(@NonNull String taskId, long nowNanos) 634 throws McpTaskNotFoundException { 635 removeExpiredEntries(nowNanos); 636 Entry entry = this.entries.get(taskId); 637 if (entry == null) 638 throw new McpTaskNotFoundException(); 639 return entry; 640 } 641 642 private boolean isAuthorized(@NonNull Entry entry, 643 @NonNull McpRequestContext requestContext) { 644 return entry.endpointPath.equals(requestContext.getEndpoint().getPath()) 645 && entry.authorizationPartitionKey.equals(requestContext 646 .getAdmissionIdentity().getAuthorizationPartitionKey()); 647 } 648 649 @GuardedBy("lock") 650 private void removeExpiredEntries(long nowNanos) { 651 Iterator<Entry> iterator = this.entries.values().iterator(); 652 while (iterator.hasNext()) { 653 Entry entry = iterator.next(); 654 if (nowNanos - entry.createdNanos >= this.taskTimeToLiveNanos) 655 iterator.remove(); 656 } 657 } 658 659 private static void requireNonterminal(@NonNull McpTask task) { 660 if (isTerminal(task.getTaskStatus())) 661 throw new IllegalStateException("An MCP task's terminal state is immutable."); 662 } 663 664 private static boolean isTerminal(@NonNull McpTaskStatus taskStatus) { 665 return taskStatus == McpTaskStatus.COMPLETED 666 || taskStatus == McpTaskStatus.FAILED 667 || taskStatus == McpTaskStatus.CANCELED; 668 } 669 670 @NonNull 671 private static String requireInputRequestKey(@NonNull String key) { 672 return requireNonNull(key); 673 } 674 675 @NonNull 676 private static IllegalArgumentException reusedInputRequestKey( 677 @NonNull String key) { 678 requireNonNull(key); 679 return new IllegalArgumentException( 680 "An MCP task input-request key must be unique for the task lifetime."); 681 } 682 683 @NonNull 684 private static Duration requireWholePositiveMilliseconds( 685 @NonNull Duration duration, @NonNull String name) { 686 requireNonNull(duration); 687 long milliseconds; 688 long nanoseconds; 689 try { 690 milliseconds = duration.toMillis(); 691 nanoseconds = duration.toNanos(); 692 } catch (ArithmeticException exception) { 693 throw new IllegalArgumentException( 694 name + " must fit in a signed 64-bit nanosecond count.", 695 exception); 696 } 697 if (milliseconds <= 0 || nanoseconds <= 0 698 || !duration.equals(Duration.ofMillis(milliseconds))) 699 throw new IllegalArgumentException( 700 name + " must be a positive whole-millisecond duration."); 701 return duration; 702 } 703 704 /** 705 * Single-threaded builder for an in-memory task manager. 706 * 707 * @author <a href="https://www.revetkn.com">Mark Allen</a> 708 */ 709 @NotThreadSafe 710 public static final class Builder { 711 private int maximumRetainedTasks; 712 @NonNull 713 private Duration taskTimeToLive; 714 @NonNull 715 private Duration pollInterval; 716 717 private Builder() { 718 this.maximumRetainedTasks = DEFAULT_MAXIMUM_RETAINED_TASKS; 719 this.taskTimeToLive = DEFAULT_TASK_TIME_TO_LIVE; 720 this.pollInterval = DEFAULT_POLL_INTERVAL; 721 } 722 723 /** 724 * Sets the positive finite task capacity. The default is {@code 1024}. 725 * A null value restores the default. 726 * 727 * @param maximumRetainedTasks maximum retained tasks, or null for default 728 * @return this builder 729 */ 730 @NonNull 731 public Builder maximumRetainedTasks( 732 @Nullable Integer maximumRetainedTasks) { 733 if (maximumRetainedTasks == null) { 734 this.maximumRetainedTasks = DEFAULT_MAXIMUM_RETAINED_TASKS; 735 return this; 736 } 737 if (maximumRetainedTasks < 1) 738 throw new IllegalArgumentException( 739 "maximumRetainedTasks must be positive"); 740 this.maximumRetainedTasks = maximumRetainedTasks; 741 return this; 742 } 743 744 /** 745 * Sets every task's finite lifetime from creation. The default is one 746 * hour. A null value restores the default. 747 * 748 * @param taskTimeToLive positive whole-millisecond lifetime, or null for 749 * default 750 * @return this builder 751 */ 752 @NonNull 753 public Builder taskTimeToLive(@Nullable Duration taskTimeToLive) { 754 this.taskTimeToLive = taskTimeToLive == null 755 ? DEFAULT_TASK_TIME_TO_LIVE 756 : requireWholePositiveMilliseconds(taskTimeToLive, 757 "taskTimeToLive"); 758 return this; 759 } 760 761 /** 762 * Sets every task's suggested polling interval. The default is one 763 * second. A null value restores the default. 764 * 765 * @param pollInterval positive whole-millisecond interval, or null for 766 * default 767 * @return this builder 768 */ 769 @NonNull 770 public Builder pollInterval(@Nullable Duration pollInterval) { 771 this.pollInterval = pollInterval == null 772 ? DEFAULT_POLL_INTERVAL 773 : requireWholePositiveMilliseconds(pollInterval, 774 "pollInterval"); 775 return this; 776 } 777 778 /** @return a new independent in-memory task manager */ 779 @NonNull 780 public McpInMemoryTaskManager build() { 781 return new McpInMemoryTaskManager(this); 782 } 783 } 784 785 private static final class Entry { 786 @NonNull 787 private McpTask task; 788 @NonNull 789 private final String endpointPath; 790 @NonNull 791 private final Optional<@NonNull String> authorizationPartitionKey; 792 private final long createdNanos; 793 @NonNull 794 private final Set<@NonNull String> issuedInputRequestKeys; 795 @NonNull 796 private final Map<@NonNull String, @NonNull McpJsonValue> 797 pendingInputResponses; 798 private boolean cancelationRequested; 799 800 private Entry(@NonNull McpTask task, @NonNull String endpointPath, 801 @NonNull Optional<@NonNull String> authorizationPartitionKey, 802 long createdNanos) { 803 this.task = requireNonNull(task); 804 this.endpointPath = requireNonNull(endpointPath); 805 this.authorizationPartitionKey = requireNonNull( 806 authorizationPartitionKey); 807 this.createdNanos = createdNanos; 808 this.issuedInputRequestKeys = new HashSet<>(); 809 this.pendingInputResponses = new LinkedHashMap<>(); 810 this.cancelationRequested = false; 811 } 812 } 813}