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}