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.internal.mcp.protocol.McpAppMimeType;
020import com.soklet.internal.mcp.protocol.McpEndpointPathLimit;
021import org.jspecify.annotations.NonNull;
022import org.jspecify.annotations.Nullable;
023
024import javax.annotation.concurrent.NotThreadSafe;
025import javax.annotation.concurrent.ThreadSafe;
026import java.net.URI;
027import java.util.LinkedHashMap;
028import java.util.LinkedHashSet;
029import java.util.List;
030import java.util.Map;
031import java.util.Optional;
032import java.util.Set;
033
034import static java.util.Objects.requireNonNull;
035
036/**
037 * An immutable MCP endpoint registration.
038 * <p>
039 * An endpoint may contain only its path and server information. Such an
040 * operation-free endpoint remains valid and advertises no optional operation
041 * capabilities.
042 *
043 * @author <a href="https://www.revetkn.com">Mark Allen</a>
044 */
045@ThreadSafe
046public final class McpEndpoint {
047        @NonNull
048        private final String path;
049        @NonNull
050        private final Set<@NonNull McpProtocolVersion> protocolVersions;
051        @NonNull
052        private final Set<@NonNull McpProtocolVersion> taskProtocolVersions;
053        @NonNull
054        private final Set<@NonNull McpProtocolVersion> sessionProtocolVersions;
055        @NonNull
056        private final Set<@NonNull McpProtocolVersion> subscriptionProtocolVersions;
057        @NonNull
058        private final McpImplementation serverInformation;
059        private final boolean serverInformationIncluded;
060        @Nullable
061        private final String instructions;
062        @NonNull
063        private final List<@NonNull McpToolRegistration<?>> toolRegistrations;
064        @NonNull
065        private final List<@NonNull McpPromptRegistration> promptRegistrations;
066        @NonNull
067        private final List<@NonNull McpResourceRegistration> resourceRegistrations;
068        @NonNull
069        private final List<@NonNull McpSkillRegistration> skillRegistrations;
070        @NonNull
071        private final List<@NonNull McpSkillGroup> skillGroups;
072        @NonNull
073        private final McpSkillEndpointIndex skillIndex;
074        @Nullable
075        private final McpSkillListHandler skillListHandler;
076        @NonNull
077        private final Set<@NonNull McpProtocolVersion> skillListHandlerProtocolVersions;
078        @NonNull
079        private final McpCachePolicy skillListCachePolicy;
080        @Nullable
081        private final McpResourceListHandler resourceListHandler;
082        @NonNull
083        private final Set<@NonNull McpProtocolVersion> resourceListHandlerProtocolVersions;
084        @NonNull
085        private final McpCachePolicy resourceListCachePolicy;
086        @NonNull
087        private final McpCachePolicy resourceTemplateListCachePolicy;
088        @Nullable
089        private final String toolRateLimiterName;
090        @Nullable
091        private final McpRateLimiter toolRateLimiter;
092        @Nullable
093        private final McpSubscriptionConfig subscriptionConfig;
094
095        /**
096         * Vends a builder primed with its required construction values.
097         *
098         * @param path the normalized absolute endpoint path in ASCII raw URI form;
099         *             percent-encode non-ASCII characters. The path is not rewritten.
100         * @param implementation implementation information advertised by the endpoint
101         * @param protocolVersions nonempty exact revisions served at this URL
102         * @return a builder for endpoint registrations
103         * @throws NullPointerException if an argument is null
104         * @throws IllegalArgumentException if the path is not a non-root absolute
105         *                                  path, is not valid ASCII raw URI form,
106         *                                  contains a query or fragment, has a trailing
107         *                                  slash, repeated slashes, whitespace or dot
108         *                                  segments, or exceeds 8192 bytes, or the
109         *                                  server name or version is blank
110         */
111        @NonNull
112        public static Builder withPath(@NonNull String path,
113                        @NonNull McpImplementation implementation,
114                        @NonNull Set<@NonNull McpProtocolVersion> protocolVersions) {
115                return new Builder(normalizePath(path), implementation,
116                                McpProtocolVersion.requiredSet(protocolVersions));
117        }
118
119        private McpEndpoint(@NonNull Builder builder) {
120                requireNonNull(builder);
121
122                this.path = builder.path;
123                this.protocolVersions = builder.protocolVersions;
124                this.taskProtocolVersions = builder.taskProtocolVersions;
125                this.sessionProtocolVersions = builder.sessionProtocolVersions;
126                this.subscriptionProtocolVersions = builder.subscriptionProtocolVersions;
127                this.serverInformation = builder.serverInformation;
128                this.serverInformationIncluded = builder.serverInformationIncluded;
129                this.instructions = builder.instructions;
130                this.toolRegistrations = List.copyOf(builder.toolRegistrations);
131                this.promptRegistrations = List.copyOf(builder.promptRegistrations);
132                this.resourceRegistrations = List.copyOf(builder.resourceRegistrations);
133                this.skillRegistrations = List.copyOf(builder.skillRegistrations);
134                this.skillGroups = List.copyOf(builder.skillGroups);
135                this.skillIndex = McpSkillEndpointIndex.from(this.skillRegistrations, this.skillGroups,
136                                this.resourceRegistrations);
137                this.skillListHandler = builder.skillListHandler;
138                this.skillListHandlerProtocolVersions = builder.skillListHandlerProtocolVersions;
139                this.skillListCachePolicy = builder.skillListCachePolicy;
140                this.resourceListHandler = builder.resourceListHandler;
141                this.resourceListHandlerProtocolVersions = builder.resourceListHandlerProtocolVersions;
142                this.resourceListCachePolicy = builder.resourceListCachePolicy;
143                this.resourceTemplateListCachePolicy =
144                                builder.resourceTemplateListCachePolicy;
145                this.toolRateLimiterName = builder.toolRateLimiterName;
146                this.toolRateLimiter = builder.toolRateLimiter;
147                this.subscriptionConfig = builder.subscriptionConfig;
148                if (this.protocolVersions.contains(McpProtocolVersion.V2025_03_26))
149                        throw new IllegalStateException(
150                                        "MCP 2025-03-26 is not implemented by this adapter" + versionContext(Set.of(McpProtocolVersion.V2025_03_26)));
151
152                requireSubset(this.taskProtocolVersions, this.protocolVersions, "Tasks");
153                requireSubset(this.sessionProtocolVersions, this.protocolVersions, "sessions");
154                if (!Set.of(McpProtocolVersion.V2025_06_18,
155                                McpProtocolVersion.V2025_11_25).containsAll(this.sessionProtocolVersions))
156                        throw new IllegalStateException(
157                                        "Sessions currently require MCP 2025-06-18 or 2025-11-25" + versionContext(this.sessionProtocolVersions));
158                requireSubset(this.subscriptionProtocolVersions, this.protocolVersions,
159                                "subscriptions");
160                requireSubset(this.skillListHandlerProtocolVersions, this.protocolVersions,
161                                "Skills-list handler");
162                requireSubset(this.resourceListHandlerProtocolVersions, this.protocolVersions,
163                                "resource-list handler");
164                if (!Set.of(McpProtocolVersion.V2026_07_28).containsAll(
165                                this.taskProtocolVersions))
166                        throw new IllegalStateException(
167                                        "Tasks currently require MCP 2026-07-28" + versionContext(this.taskProtocolVersions));
168                if (!Set.of(McpProtocolVersion.V2025_06_18, McpProtocolVersion.V2025_11_25,
169                                McpProtocolVersion.V2026_07_28).containsAll(
170                                this.subscriptionProtocolVersions))
171                        throw new IllegalStateException(
172                                        "Subscriptions currently require MCP 2025-06-18, 2025-11-25, or 2026-07-28" + versionContext(this.subscriptionProtocolVersions));
173                for (McpProtocolVersion subscriptionProtocolVersion : this.subscriptionProtocolVersions)
174                        if (subscriptionProtocolVersion != McpProtocolVersion.V2026_07_28
175                                        && !this.sessionProtocolVersions.contains(subscriptionProtocolVersion))
176                                throw new IllegalStateException(
177                                                "Legacy subscription revisions must also enable sessions" + versionContext(Set.of(subscriptionProtocolVersion)));
178                if (this.subscriptionConfig != null && this.subscriptionProtocolVersions.isEmpty())
179                        throw new IllegalStateException(
180                                        "MCP subscription configuration requires an enabled protocol revision" + versionContext(this.protocolVersions));
181
182                Set<String> toolNames = new LinkedHashSet<>();
183                for (McpToolRegistration<?> tool : this.toolRegistrations) {
184                        requireSubset(tool.getProtocolVersions(), this.protocolVersions,
185                                        "tool " + tool.getName());
186                        if (containsLegacyVersion(tool.getProtocolVersions())
187                                        && tool.getOutputSchema().isPresent()
188                                        && !McpJsonString.fromValue("object").equals(tool.getOutputSchema().orElseThrow()
189                                                        .getDocument().getMembers().get("type")))
190                                throw new IllegalStateException("MCP tool '" + tool.getName()
191                                                + "' output schema must have object type for the 2025 adapter"
192                                                + versionContext(tool.getProtocolVersions()));
193                        if (containsLegacyVersion(tool.getProtocolVersions())
194                                        && (!tool.getInputRequestDeclarations().isEmpty()
195                                                || tool.getRequestStateMode() != McpRequestStateMode.NONE
196                                                || !tool.getMirroredHeaderPlan().declarations().isEmpty()))
197                                throw new IllegalStateException(
198                                                "The 2025 tools adapter cannot serve input requests, request state, or mirrored headers for tool "
199                                                                + tool.getName() + versionContext(tool.getProtocolVersions()));
200                        tool.getAppToolMetadata().ifPresent(metadata -> {
201                                if (containsLegacyVersion(metadata.getProtocolVersions()))
202                                        throw new IllegalStateException(
203                                                        "MCP Apps metadata for tool '" + tool.getName() + "' is not implemented by the 2025 adapter"
204                                                                        + versionContext(metadata.getProtocolVersions()));
205                                if (!metadata.getVisibility().contains(McpAppToolMetadata.Visibility.MODEL)
206                                                && containsLegacyVersion(tool.getProtocolVersions()))
207                                        throw new IllegalStateException(
208                                                        "App-only tools cannot be exposed by the 2025 tools-only adapter: " + tool.getName()
209                                                                        + versionContext(tool.getProtocolVersions()));
210                        });
211                        if (tool.isTaskRequired())
212                                requireSubset(tool.getProtocolVersions(), this.taskProtocolVersions,
213                                                "task-required tool " + tool.getName());
214                        if (!toolNames.add(tool.getName()))
215                                throw new IllegalStateException(
216                                                "Duplicate MCP tool name: " + tool.getName());
217                }
218                Set<String> promptNames = new LinkedHashSet<>();
219                for (McpPromptRegistration prompt : this.promptRegistrations) {
220                        requireSubset(prompt.getProtocolVersions(), this.protocolVersions,
221                                        "prompt " + prompt.getName());
222                        if (containsLegacyVersion(prompt.getProtocolVersions())
223                                        && (!prompt.getInputRequestDeclarations().isEmpty()
224                                                || prompt.getRequestStateMode() != McpRequestStateMode.NONE))
225                                throw new IllegalStateException(
226                                                "The 2025 prompt adapter cannot serve input requests or request state for prompt "
227                                                                + prompt.getName() + versionContext(prompt.getProtocolVersions()));
228                        if (!promptNames.add(prompt.getName()))
229                                throw new IllegalStateException(
230                                                "Duplicate MCP prompt name: " + prompt.getName());
231                }
232                Map<URI, McpResourceRegistration> exactResources = new LinkedHashMap<>();
233                Set<String> resourceUriTemplates = new LinkedHashSet<>();
234                for (McpResourceRegistration resource : this.resourceRegistrations) {
235                        requireSubset(resource.getProtocolVersions(), this.protocolVersions,
236                                        "resource " + resource.getName());
237                        if (containsLegacyVersion(resource.getProtocolVersions())) {
238                                if (!resource.getInputRequestDeclarations().isEmpty()
239                                                || resource.getRequestStateMode() != McpRequestStateMode.NONE)
240                                        throw new IllegalStateException(
241                                                        "The 2025 resource adapter cannot serve input requests or request state for resource "
242                                                                        + resource.getName() + versionContext(resource.getProtocolVersions()));
243                                if (hasAppsMimeType(resource)
244                                                || resource.getMetadata().getMembers().containsKey("ui"))
245                                        throw new IllegalStateException(
246                                                        "MCP Apps resources are not implemented by the 2025 adapter: " + resource.getName()
247                                                                        + versionContext(resource.getProtocolVersions()));
248                        }
249                        if (resource.getAddressType() == McpResourceAddressType.URI) {
250                                URI uri = resource.getUri().orElseThrow();
251                                if (exactResources.putIfAbsent(uri, resource) != null)
252                                        throw new IllegalStateException(
253                                                        "Duplicate MCP exact resource URI: " + uri);
254                        } else {
255                                String uriTemplate = resource.getUriTemplate().orElseThrow();
256                                if (!resourceUriTemplates.add(uriTemplate))
257                                        throw new IllegalStateException(
258                                                        "Duplicate MCP resource URI template: " + uriTemplate);
259                        }
260                }
261                for (McpSkillRegistration skill : this.skillRegistrations)
262                        requireSkillVersions(skill);
263                for (McpSkillGroup group : this.skillGroups)
264                        for (McpSkillRegistration skill : group.getSkillRegistrations())
265                                requireSkillVersions(skill);
266                requireModernOnly(this.skillListHandlerProtocolVersions,
267                                "Skills-list handlers");
268                for (McpToolRegistration<?> tool : this.toolRegistrations) {
269                        Optional<URI> resourceUri = McpAppMetadataSupport
270                                        .effectiveToolMetadata(tool.getMetadata(),
271                                                        tool.getAppToolMetadata().orElse(null))
272                                        .flatMap(McpAppToolMetadata::getResourceUri);
273                        if (resourceUri.isPresent()) {
274                                McpResourceRegistration resource = exactResources.get(resourceUri.get());
275                                if (resource == null || !hasAppsMimeType(resource))
276                                        throw new IllegalStateException(
277                                                        "MCP Apps tool associations require an exact UI resource "
278                                                                        + "registration with the Apps MIME profile on the same endpoint for tool "
279                                                                        + tool.getName() + versionContext(tool.getProtocolVersions()));
280                                tool.getAppToolMetadata().ifPresent(metadata -> requireSubset(
281                                                metadata.getProtocolVersions(), resource.getProtocolVersions(),
282                                                "Apps UI resource for tool " + tool.getName()));
283                        }
284                }
285                McpSkillEndpointIndex.preflight(this);
286        }
287
288        private void requireSkillVersions(@NonNull McpSkillRegistration skill) {
289                requireSubset(skill.getProtocolVersions(), this.protocolVersions,
290                                "Skill " + skill.getSkillBundle().getName());
291                if (!Set.of(McpProtocolVersion.V2026_07_28).containsAll(
292                                skill.getProtocolVersions()))
293                        throw new IllegalStateException(
294                                        "Skills currently require MCP 2026-07-28: " + skill.getSkillBundle().getName()
295                                                        + versionContext(skill.getProtocolVersions()));
296        }
297
298        private void requireModernOnly(
299                        @NonNull Set<@NonNull McpProtocolVersion> protocolVersions,
300                        @NonNull String feature) {
301                if (containsLegacyVersion(protocolVersions))
302                        throw new IllegalStateException("The 2025 adapter does not implement MCP "
303                                        + feature + versionContext(protocolVersions));
304        }
305
306        private static boolean containsLegacyVersion(
307                        @NonNull Set<@NonNull McpProtocolVersion> protocolVersions) {
308                return protocolVersions.stream().anyMatch(version ->
309                                version != McpProtocolVersion.V2026_07_28);
310        }
311
312        private void requireSubset(
313                        @NonNull Set<@NonNull McpProtocolVersion> selected,
314                        @NonNull Set<@NonNull McpProtocolVersion> owner,
315                        @NonNull String feature) {
316                if (!owner.containsAll(selected))
317                        throw new IllegalStateException("MCP " + feature
318                                        + " revisions must be a subset of their owner revisions"
319                                        + versionContext(selected.stream().filter(version -> !owner.contains(version))
320                                                        .collect(java.util.stream.Collectors.toSet())));
321        }
322
323        private @NonNull String versionContext(@NonNull Set<@NonNull McpProtocolVersion> versions) {
324                return " (endpoint " + this.path + "; revisions " + versions.stream()
325                                .map(McpProtocolVersion::getWireValue).sorted().collect(java.util.stream.Collectors.joining(", ")) + ").";
326        }
327
328        private static boolean hasAppsMimeType(@NonNull McpResourceRegistration resource) {
329                String mimeType = resource.getMimeType().orElse(null);
330                if (mimeType == null)
331                        return false;
332                try {
333                        return McpAppMimeType.isAppsProfile(mimeType);
334                } catch (IllegalArgumentException exception) {
335                        return false;
336                }
337        }
338
339        private McpEndpoint(@NonNull McpEndpoint endpoint,
340                        @NonNull List<@NonNull McpSkillRegistration> skillRegistrations,
341                        @NonNull List<@NonNull McpSkillGroup> skillGroups,
342                        @NonNull McpSkillEndpointIndex skillIndex,
343                        @Nullable McpSubscriptionConfig subscriptionConfig) {
344                requireNonNull(endpoint);
345                this.path = endpoint.path;
346                this.protocolVersions = endpoint.protocolVersions;
347                this.taskProtocolVersions = endpoint.taskProtocolVersions;
348                this.sessionProtocolVersions = endpoint.sessionProtocolVersions;
349                this.subscriptionProtocolVersions = endpoint.subscriptionProtocolVersions;
350                this.serverInformation = endpoint.serverInformation;
351                this.serverInformationIncluded = endpoint.serverInformationIncluded;
352                this.instructions = endpoint.instructions;
353                this.toolRegistrations = endpoint.toolRegistrations;
354                this.promptRegistrations = endpoint.promptRegistrations;
355                this.resourceRegistrations = endpoint.resourceRegistrations;
356                this.skillRegistrations = skillRegistrations;
357                this.skillGroups = skillGroups;
358                this.skillIndex = skillIndex;
359                this.skillListHandler = endpoint.skillListHandler;
360                this.skillListHandlerProtocolVersions = endpoint.skillListHandlerProtocolVersions;
361                this.skillListCachePolicy = endpoint.skillListCachePolicy;
362                this.resourceListHandler = endpoint.resourceListHandler;
363                this.resourceListHandlerProtocolVersions = endpoint.resourceListHandlerProtocolVersions;
364                this.resourceListCachePolicy = endpoint.resourceListCachePolicy;
365                this.resourceTemplateListCachePolicy =
366                                endpoint.resourceTemplateListCachePolicy;
367                this.toolRateLimiterName = endpoint.toolRateLimiterName;
368                this.toolRateLimiter = endpoint.toolRateLimiter;
369                this.subscriptionConfig = subscriptionConfig;
370        }
371
372        /**
373         * The normalized absolute endpoint path.
374         *
375         * @return the endpoint path
376         */
377        @NonNull
378        public String getPath() {
379                return this.path;
380        }
381
382        /** @return exact protocol revisions served at this endpoint */
383        @NonNull
384        public Set<@NonNull McpProtocolVersion> getProtocolVersions() {
385                return this.protocolVersions;
386        }
387
388        /** @return exact revisions that enable Tasks on this endpoint */
389        @NonNull
390        public Set<@NonNull McpProtocolVersion> getTaskProtocolVersions() {
391                return this.taskProtocolVersions;
392        }
393
394        /**
395         * Returns the exact session-enabled subset of this endpoint's revisions.
396         * Only MCP {@code 2025-06-18} and {@code 2025-11-25} are eligible;
397         * Soklet's {@code 2026-07-28} implementation does not use sessions.
398         *
399         * @return immutable session revisions, or an empty set when disabled
400         */
401        @NonNull
402        public Set<@NonNull McpProtocolVersion> getSessionProtocolVersions() {
403                return this.sessionProtocolVersions;
404        }
405
406        /**
407         * Returns exact subscription-enabled revisions. Modern {@code 2026-07-28}
408         * uses {@code subscriptions/listen}; {@code 2025-06-18} and
409         * {@code 2025-11-25} use session GET delivery and must also enable sessions.
410         * Legacy delivery additionally requires the server's HTTP admission
411         * controller and an effective event-source family set.
412         *
413         * @return immutable exact subscription-enabled revisions
414         */
415        @NonNull
416        public Set<@NonNull McpProtocolVersion> getSubscriptionProtocolVersions() {
417                return this.subscriptionProtocolVersions;
418        }
419
420        /**
421         * The required implementation information advertised by this endpoint.
422         *
423         * @return the server implementation information
424         */
425        @NonNull
426        public McpImplementation getServerInfo() {
427                return this.serverInformation;
428        }
429
430        /**
431         * Indicates whether Soklet includes the configured server implementation at
432         * {@code _meta["io.modelcontextprotocol/serverInfo"]} in MCP results.
433         *
434         * @return {@code true} when MCP result metadata includes server information
435         */
436        @NonNull
437        public Boolean isServerInfoIncluded() {
438                return this.serverInformationIncluded;
439        }
440
441        /**
442         * Optional human-readable instructions for clients using this endpoint.
443         *
444         * @return the instructions, or the empty optional if none were configured
445         */
446        @NonNull
447        public Optional<@NonNull String> getInstructions() {
448                return Optional.ofNullable(this.instructions);
449        }
450
451        /**
452         * Returns the tools exposed by this endpoint in registration order.
453         *
454         * @return immutable tool registrations
455         */
456        @NonNull
457        public List<@NonNull McpToolRegistration<?>> getToolRegistrations() {
458                return this.toolRegistrations;
459        }
460
461        /**
462         * Returns the prompts exposed by this endpoint in registration order.
463         *
464         * @return immutable prompt registrations
465         */
466        @NonNull
467        public List<@NonNull McpPromptRegistration> getPromptRegistrations() {
468                return this.promptRegistrations;
469        }
470
471        /**
472         * Returns exact-URI and URI-template resource registrations in registration
473         * order.
474         * <p>
475         * When {@link #getResourceListHandler()} is empty, Soklet derives the static
476         * {@code resources/list} page from only the exact-URI registrations in this
477         * list. Template registrations are advertised separately by
478         * {@code resources/templates/list}.
479         *
480         * @return immutable resource registrations
481         */
482        @NonNull
483        public List<@NonNull McpResourceRegistration> getResourceRegistrations() {
484                return this.resourceRegistrations;
485        }
486
487        /**
488         * Returns only standalone Skills registrations in supplied order. Group members
489         * remain in {@link #getSkillGroups()}; this getter does not flatten them.
490         * Skills manifests and files are served only on their declared protocol revisions.
491         *
492         * @return immutable standalone Skills registrations
493         */
494        @NonNull
495        public List<@NonNull McpSkillRegistration> getSkillRegistrations() {
496                return this.skillRegistrations;
497        }
498
499        /**
500         * Returns explicit Skills locale groups in supplied order, without selecting
501         * a locale or granting access to any member.
502         *
503         * @return immutable Skills groups
504         */
505        @NonNull
506        public List<@NonNull McpSkillGroup> getSkillGroups() {
507                return this.skillGroups;
508        }
509
510        @NonNull
511        McpSkillEndpointIndex skillIndex() { return this.skillIndex; }
512
513        /**
514         * Returns the optional application-owned {@code skills/list} page handler.
515         * When absent, Soklet produces one bounded automatic page. A custom handler
516         * owns pagination and retained snapshots, not canonical skill-file reads.
517         *
518         * @return custom Skills-list handler, or empty for automatic listing
519         */
520        @NonNull
521        public Optional<@NonNull McpSkillListHandler> getSkillListHandler() {
522                return Optional.ofNullable(this.skillListHandler);
523        }
524
525        /** @return revisions on which the custom Skills-list handler applies */
526        @NonNull
527        public Set<@NonNull McpProtocolVersion> getSkillListHandlerProtocolVersions() {
528                return this.skillListHandlerProtocolVersions;
529        }
530
531        /** @return fixed Skills-list cache scope and default time to live */
532        @NonNull
533        public McpCachePolicy getSkillListCachePolicy() { return this.skillListCachePolicy; }
534
535        /**
536         * Returns the optional sole custom {@code resources/list} handler.
537         * <p>
538         * When enabled for the selected revision, the returned handler is
539         * authoritative; Soklet does not merge exact registrations into its pages.
540         * Otherwise the endpoint uses the static fallback: a single page on
541         * {@code 2026-07-28}, or bounded framework pages on {@code 2025-06-18} and
542         * {@code 2025-11-25}.
543         *
544         * @return custom resource-list handler, or empty for the static fallback
545         */
546        @NonNull
547        public Optional<@NonNull McpResourceListHandler> getResourceListHandler() {
548                return Optional.ofNullable(this.resourceListHandler);
549        }
550
551        /** @return revisions on which the custom resource-list handler applies */
552        @NonNull
553        public Set<@NonNull McpProtocolVersion> getResourceListHandlerProtocolVersions() {
554                return this.resourceListHandlerProtocolVersions;
555        }
556
557        /**
558         * Returns the fixed cache policy for every {@code resources/list} page.
559         *
560         * @return resources-list cache policy
561         */
562        @NonNull
563        public McpCachePolicy getResourceListCachePolicy() {
564                return this.resourceListCachePolicy;
565        }
566
567        /**
568         * Returns the fixed cache policy for {@code resources/templates/list}.
569         *
570         * @return resource-template-list cache policy
571         */
572        @NonNull
573        public McpCachePolicy getResourceTemplateListCachePolicy() {
574                return this.resourceTemplateListCachePolicy;
575        }
576
577        /**
578         * Returns the named tool-limiter override.
579         * <p>
580         * At most one of this value and {@link #getToolRateLimiter()} is present.
581         *
582         * @return registry limiter name, or the empty optional for a direct or
583         * inherited limiter
584         */
585        @NonNull
586        public Optional<@NonNull String> getToolRateLimiterName() {
587                return Optional.ofNullable(this.toolRateLimiterName);
588        }
589
590        /**
591         * Returns the direct tool-limiter override.
592         * <p>
593         * At most one of this value and {@link #getToolRateLimiterName()} is present.
594         *
595         * @return direct limiter, or the empty optional for a named or inherited
596         * limiter
597         */
598        @NonNull
599        public Optional<@NonNull McpRateLimiter> getToolRateLimiter() {
600                return Optional.ofNullable(this.toolRateLimiter);
601        }
602
603        /**
604         * Returns this endpoint's subscription-change configuration.
605         *
606         * @return subscription configuration, or the empty optional if none was
607         * configured
608         */
609        @NonNull
610        public Optional<@NonNull McpSubscriptionConfig> getSubscriptionConfig() {
611                return Optional.ofNullable(this.subscriptionConfig);
612        }
613
614        @NonNull
615        McpEndpoint withSubscriptionConfig(
616                        @NonNull McpSubscriptionConfig subscriptionConfig) {
617                if (this.subscriptionProtocolVersions.isEmpty())
618                        throw new IllegalStateException(
619                                        "MCP subscription configuration requires an enabled protocol revision.");
620                return new McpEndpoint(this, this.skillRegistrations, this.skillGroups, this.skillIndex,
621                                requireNonNull(subscriptionConfig));
622        }
623
624        @NonNull
625        McpEndpoint withSkillRegistrations(
626                        @NonNull List<@NonNull McpSkillRegistration> skillRegistrations) {
627                List<McpSkillRegistration> snapshot = List.copyOf(skillRegistrations);
628                McpSkillEndpointIndex skillIndex = McpSkillEndpointIndex.from(snapshot,
629                                this.skillGroups, this.resourceRegistrations);
630                McpEndpoint endpoint = new McpEndpoint(this, snapshot, this.skillGroups, skillIndex,
631                                this.subscriptionConfig);
632                for (McpSkillRegistration skill : snapshot)
633                        endpoint.requireSkillVersions(skill);
634                McpSkillEndpointIndex.preflight(endpoint);
635                return endpoint;
636        }
637
638        @NonNull
639        McpEndpoint withSkillGroups(
640                        @NonNull List<@NonNull McpSkillGroup> skillGroups) {
641                List<McpSkillGroup> snapshot = List.copyOf(skillGroups);
642                McpSkillEndpointIndex skillIndex = McpSkillEndpointIndex.from(this.skillRegistrations,
643                                snapshot, this.resourceRegistrations);
644                McpEndpoint endpoint = new McpEndpoint(this, this.skillRegistrations, snapshot, skillIndex,
645                                this.subscriptionConfig);
646                for (McpSkillGroup group : snapshot)
647                        for (McpSkillRegistration skill : group.getSkillRegistrations())
648                                endpoint.requireSkillVersions(skill);
649                McpSkillEndpointIndex.preflight(endpoint);
650                return endpoint;
651        }
652
653        @NonNull
654        static String normalizePath(@NonNull String path) {
655                return McpEndpointPathLimit.requireValidWirePath(path);
656        }
657
658        /**
659         * Builder for immutable {@link McpEndpoint} registrations.
660         * <p>
661         * This class is intended for use by a single thread.
662         *
663         * @author <a href="https://www.revetkn.com">Mark Allen</a>
664         */
665        @NotThreadSafe
666        public static final class Builder {
667                @NonNull
668                private final String path;
669                @NonNull
670                private final Set<@NonNull McpProtocolVersion> protocolVersions;
671                @NonNull
672                private Set<@NonNull McpProtocolVersion> taskProtocolVersions = Set.of();
673                @NonNull
674                private Set<@NonNull McpProtocolVersion> sessionProtocolVersions = Set.of();
675                @NonNull
676                private Set<@NonNull McpProtocolVersion> subscriptionProtocolVersions = Set.of();
677                @NonNull
678                private McpImplementation serverInformation;
679                private boolean serverInformationIncluded;
680                @Nullable
681                private String instructions;
682                @NonNull
683                private List<@NonNull McpToolRegistration<?>> toolRegistrations;
684                @NonNull
685                private List<@NonNull McpPromptRegistration> promptRegistrations;
686                @NonNull
687                private List<@NonNull McpResourceRegistration> resourceRegistrations;
688                @NonNull
689                private List<@NonNull McpSkillRegistration> skillRegistrations;
690                @NonNull
691                private List<@NonNull McpSkillGroup> skillGroups;
692                @Nullable
693                private McpSkillListHandler skillListHandler;
694                @NonNull
695                private Set<@NonNull McpProtocolVersion> skillListHandlerProtocolVersions = Set.of();
696                @NonNull
697                private McpCachePolicy skillListCachePolicy;
698                @Nullable
699                private McpResourceListHandler resourceListHandler;
700                @NonNull
701                private Set<@NonNull McpProtocolVersion> resourceListHandlerProtocolVersions = Set.of();
702                @NonNull
703                private McpCachePolicy resourceListCachePolicy;
704                @NonNull
705                private McpCachePolicy resourceTemplateListCachePolicy;
706                @Nullable
707                private String toolRateLimiterName;
708                @Nullable
709                private McpRateLimiter toolRateLimiter;
710                @Nullable
711                private McpSubscriptionConfig subscriptionConfig;
712
713                private Builder(@NonNull String path,
714                                @NonNull McpImplementation implementation,
715                                @NonNull Set<@NonNull McpProtocolVersion> protocolVersions) {
716                        this.path = requireNonNull(path);
717                        this.protocolVersions = requireNonNull(protocolVersions);
718                        this.serverInformation = requireServerInformation(implementation);
719                        this.serverInformationIncluded = true;
720                        this.toolRegistrations = List.of();
721                        this.promptRegistrations = List.of();
722                        this.resourceRegistrations = List.of();
723                        this.skillRegistrations = List.of();
724                        this.skillGroups = List.of();
725                        this.skillListCachePolicy = McpCachePolicy.privateNoCacheInstance();
726                        this.resourceListCachePolicy =
727                                        McpCachePolicy.privateNoCacheInstance();
728                        this.resourceTemplateListCachePolicy =
729                                        McpCachePolicy.privateNoCacheInstance();
730                }
731
732                /**
733                 * Enables Tasks for an explicit subset of endpoint revisions. An empty set
734                 * disables Tasks. The task manager remains a server dependency.
735                 *
736                 * @param protocolVersions exact task-enabled revisions
737                 * @return this builder
738                 */
739                @NonNull
740                public Builder taskProtocolVersions(
741                                @NonNull Set<@NonNull McpProtocolVersion> protocolVersions) {
742                        this.taskProtocolVersions = McpProtocolVersion.optionalSet(protocolVersions);
743                        return this;
744                }
745
746                /**
747                 * Enables sessions for an explicit subset of served endpoint revisions.
748                 * Only {@code 2025-06-18} and {@code 2025-11-25} are eligible; an empty
749                 * set disables sessions. {@code 2026-07-28} does not use sessions.
750                 * The server must configure {@link McpSessionConfig}. A session-enabled
751                 * revision requires a session ID after initialization; it never silently
752                 * falls back to stateless operation. The supplied set is copied.
753                 *
754                 * @param protocolVersions exact session-enabled revisions
755                 * @return this builder
756                 * @throws NullPointerException if the set or an item is null
757                 */
758                @NonNull
759                public Builder sessionProtocolVersions(
760                                @NonNull Set<@NonNull McpProtocolVersion> protocolVersions) {
761                        this.sessionProtocolVersions = McpProtocolVersion.optionalSet(protocolVersions);
762                        return this;
763                }
764
765                /**
766                 * Enables subscriptions for an explicit subset of endpoint revisions.
767                 * Modern {@code 2026-07-28} uses {@code subscriptions/listen}; the two
768                 * supported 2025 revisions require matching session revisions, server HTTP
769                 * admission and effective event sources for GET delivery. An empty set
770                 * disables subscriptions. URI updates require an explicit URI authorizer;
771                 * legacy catalog-only delivery does not.
772                 *
773                 * @param protocolVersions exact subscription-enabled revisions
774                 * @return this builder
775                 */
776                @NonNull
777                public Builder subscriptionProtocolVersions(
778                                @NonNull Set<@NonNull McpProtocolVersion> protocolVersions) {
779                        this.subscriptionProtocolVersions = McpProtocolVersion.optionalSet(protocolVersions);
780                        return this;
781                }
782
783                /**
784                 * Sets the required implementation information advertised by this endpoint.
785                 *
786                 * @param implementation the server implementation information
787                 * @return this builder
788                 * @throws IllegalArgumentException if the server name or version is blank
789                 */
790                @NonNull
791                public Builder serverInfo(
792                                @NonNull McpImplementation implementation) {
793                        this.serverInformation = requireServerInformation(implementation);
794                        return this;
795                }
796
797                @NonNull
798                private static McpImplementation requireServerInformation(
799                                @NonNull McpImplementation implementation) {
800                        requireNonNull(implementation);
801                        if (implementation.getName().isBlank() || implementation.getVersion().isBlank())
802                                throw new IllegalArgumentException(
803                                                "MCP server implementation name and version must not be blank.");
804                        return implementation;
805                }
806
807                /**
808                 * Controls whether Soklet includes the configured server implementation at
809                 * {@code _meta["io.modelcontextprotocol/serverInfo"]} in MCP results. The
810                 * default is {@code true}.
811                 *
812                 * @param serverInfoIncluded whether MCP result metadata includes server
813                 *                           information, or null to restore the default
814                 * @return this builder
815                 */
816                @NonNull
817                public Builder serverInfoIncluded(
818                                @Nullable Boolean serverInfoIncluded) {
819                        this.serverInformationIncluded = serverInfoIncluded == null
820                                        ? true : serverInfoIncluded;
821                        return this;
822                }
823
824                /**
825                 * Sets nonblank human-readable instructions for clients using this endpoint.
826                 *
827                 * @param instructions the endpoint instructions, or null to clear them
828                 * @return this builder
829                 * @throws IllegalArgumentException if the instructions are blank
830                 */
831                @NonNull
832                public Builder instructions(@Nullable String instructions) {
833                        if (instructions != null) McpContentValueSupport.requireWellFormedString(instructions);
834                        if (instructions != null && instructions.isBlank())
835                                throw new IllegalArgumentException(
836                                                "MCP endpoint instructions must not be blank.");
837
838                        this.instructions = instructions;
839                        return this;
840                }
841
842                /**
843                 * Replaces tool registrations in supplied order.
844                 * Null or empty clears the property. The complete list is validated and
845                 * snapshotted before replacing the prior value.
846                 *
847                 * @param toolRegistrations tool registrations, or null to clear
848                 * @return this builder
849                 * @throws NullPointerException if a list element is null
850                 */
851                @NonNull
852                public Builder toolRegistrations(
853                                @Nullable List<@NonNull McpToolRegistration<?>> toolRegistrations) {
854                        this.toolRegistrations = toolRegistrations == null ? List.of()
855                                        : List.copyOf(toolRegistrations);
856                        return this;
857                }
858
859                /**
860                 * Replaces prompt registrations in supplied order.
861                 * Null or empty clears the property. The complete list is validated and
862                 * snapshotted before replacing the prior value.
863                 *
864                 * @param promptRegistrations prompt registrations, or null to clear
865                 * @return this builder
866                 * @throws NullPointerException if a list element is null
867                 */
868                @NonNull
869                public Builder promptRegistrations(
870                                @Nullable List<@NonNull McpPromptRegistration> promptRegistrations) {
871                        this.promptRegistrations = promptRegistrations == null ? List.of()
872                                        : List.copyOf(promptRegistrations);
873                        return this;
874                }
875
876                /**
877                 * Replaces resource registrations in supplied order.
878                 * Null or empty clears the property. The complete list is validated and
879                 * snapshotted before replacing the prior value.
880                 *
881                 * @param resourceRegistrations resource registrations, or null to clear
882                 * @return this builder
883                 * @throws NullPointerException if a list element is null
884                 */
885                @NonNull
886                public Builder resourceRegistrations(
887                                @Nullable List<@NonNull McpResourceRegistration> resourceRegistrations) {
888                        this.resourceRegistrations = resourceRegistrations == null ? List.of()
889                                        : List.copyOf(resourceRegistrations);
890                        return this;
891                }
892
893                /**
894                 * Replaces standalone Skills registrations in supplied order. Null or empty
895                 * clears this property without changing groups. The complete list is
896                 * snapshotted before replacing the prior value. Endpoint construction checks
897                 * duplicate identities, listing names, shared files and resource collisions
898                 * across both sources, independent of setter order.
899                 *
900                 * @param skillRegistrations standalone Skills registrations, or null to clear
901                 * @return this builder
902                 * @throws NullPointerException if a list element is null
903                 */
904                @NonNull
905                public Builder skillRegistrations(
906                                @Nullable List<@NonNull McpSkillRegistration> skillRegistrations) {
907                        this.skillRegistrations = skillRegistrations == null ? List.of() : List.copyOf(skillRegistrations);
908                        return this;
909                }
910
911                /**
912                 * Replaces explicit Skills locale groups in supplied order. Null or empty
913                 * clears this property without changing standalone registrations. The
914                 * complete list is snapshotted before replacing the prior value. Empty
915                 * groups are permitted but contribute no listing name or file owner.
916                 *
917                 * @param skillGroups Skills groups, or null to clear
918                 * @return this builder
919                 * @throws NullPointerException if a list element is null
920                 */
921                @NonNull
922                public Builder skillGroups(@Nullable List<@NonNull McpSkillGroup> skillGroups) {
923                        this.skillGroups = skillGroups == null ? List.of() : List.copyOf(skillGroups);
924                        return this;
925                }
926
927                /**
928                 * Installs the application-owned {@code skills/list} page handler.
929                 * Each invocation replaces the prior handler. Null restores bounded
930                 * automatic single-page listing. The handler owns authenticated cursors and
931                 * snapshot restoration, while Soklet validates page membership and current
932                 * access without changing canonical file-read handling.
933                 *
934                 * @param skillListHandler custom Skills-list handler, or null for automatic listing
935                 * @param protocolVersions nonempty handler revisions, or empty when clearing
936                 * @return this builder
937                 */
938                @NonNull
939                public Builder skillListHandler(@Nullable McpSkillListHandler skillListHandler,
940                                @NonNull Set<@NonNull McpProtocolVersion> protocolVersions) {
941                        Set<McpProtocolVersion> versions = skillListHandler == null
942                                        ? McpProtocolVersion.optionalSet(protocolVersions)
943                                        : McpProtocolVersion.requiredSet(protocolVersions);
944                        if (skillListHandler == null && !versions.isEmpty())
945                                throw new IllegalArgumentException(
946                                                "A null MCP Skills-list handler requires an empty version set.");
947                        this.skillListHandler = skillListHandler;
948                        this.skillListHandlerProtocolVersions = versions;
949                        return this;
950                }
951
952                /**
953                 * Sets fixed cache scope and default freshness for {@code skills/list}.
954                 * The default is private scope with zero time to live. Page overrides affect
955                 * only freshness and remain subject to localization and security clamps.
956                 *
957                 * @param skillListCachePolicy Skills-list cache policy, or null to restore the default
958                 * @return this builder
959                 */
960                @NonNull
961                public Builder skillListCachePolicy(@Nullable McpCachePolicy skillListCachePolicy) {
962                        this.skillListCachePolicy = skillListCachePolicy == null
963                                        ? McpCachePolicy.privateNoCacheInstance() : skillListCachePolicy;
964                        return this;
965                }
966
967                /**
968                 * Installs the custom {@code resources/list} handler.
969                 * <p>
970                 * A custom handler is authoritative for every returned page; exact resource
971                 * registrations are not merged automatically. Every descriptor with an
972                 * exact {@code uri} must identify either an exact-URI resource registration
973                 * or a matching URI-template registration on this endpoint. Soklet validates
974                 * the complete page after the handler returns; a violation rejects the request
975                 * with JSON-RPC error {@code -32603} and HTTP
976                 * status {@code 200} on {@code 2026-07-28} (the 2025 profiles also retain
977                 * in-band operation errors). Null selects the static fallback: a single page on
978                 * {@code 2026-07-28}, or bounded framework pages on {@code 2025-06-18} and
979                 * {@code 2025-11-25}.
980                 * Sequential calls are last-call-wins.
981                 *
982                 * @param resourceListHandler custom list handler, or null for the static
983                 *                            fallback
984                 * @param protocolVersions nonempty handler revisions, or empty when clearing
985                 * @return this builder
986                 */
987                @NonNull
988                public Builder resourceListHandler(
989                                @Nullable McpResourceListHandler resourceListHandler,
990                                @NonNull Set<@NonNull McpProtocolVersion> protocolVersions) {
991                        Set<McpProtocolVersion> versions = resourceListHandler == null
992                                        ? McpProtocolVersion.optionalSet(protocolVersions)
993                                        : McpProtocolVersion.requiredSet(protocolVersions);
994                        if (resourceListHandler == null && !versions.isEmpty())
995                                throw new IllegalArgumentException(
996                                                "A null MCP resource-list handler requires an empty version set.");
997                        this.resourceListHandler = resourceListHandler;
998                        this.resourceListHandlerProtocolVersions = versions;
999                        return this;
1000                }
1001
1002                /**
1003                 * Sets the fixed scope and default time to live for every
1004                 * {@code resources/list} page. The default is private scope with a zero
1005                 * time to live.
1006                 *
1007                 * @param resourceListCachePolicy resources-list cache policy, or null to
1008                 *                                restore the default
1009                 * @return this builder
1010                 */
1011                @NonNull
1012                public Builder resourceListCachePolicy(
1013                                @Nullable McpCachePolicy resourceListCachePolicy) {
1014                        this.resourceListCachePolicy = resourceListCachePolicy == null
1015                                        ? McpCachePolicy.privateNoCacheInstance()
1016                                        : resourceListCachePolicy;
1017                        return this;
1018                }
1019
1020                /**
1021                 * Sets the fixed scope and default time to live for
1022                 * {@code resources/templates/list}. The default is private scope with a
1023                 * zero time to live.
1024                 *
1025                 * @param resourceTemplateListCachePolicy resource-template-list cache
1026                 *                                        policy, or null to restore the
1027                 *                                        default
1028                 * @return this builder
1029                 */
1030                @NonNull
1031                public Builder resourceTemplateListCachePolicy(
1032                                @Nullable McpCachePolicy resourceTemplateListCachePolicy) {
1033                        this.resourceTemplateListCachePolicy =
1034                                        resourceTemplateListCachePolicy == null
1035                                                        ? McpCachePolicy.privateNoCacheInstance()
1036                                                        : resourceTemplateListCachePolicy;
1037                        return this;
1038                }
1039
1040                /**
1041                 * Sets a named tool-limiter override.
1042                 * <p>
1043                 * Sequential named and direct setter calls are last-call-wins. This call
1044                 * clears any direct limiter previously configured on this builder.
1045                 *
1046                 * @param toolRateLimiterName nonblank name in the server limiter registry,
1047                 *                            or null to clear the endpoint override
1048                 * @return this builder
1049                 */
1050                @NonNull
1051                public Builder toolRateLimiterName(@Nullable String toolRateLimiterName) {
1052                        if (toolRateLimiterName == null) {
1053                                this.toolRateLimiterName = null;
1054                                this.toolRateLimiter = null;
1055                                return this;
1056                        }
1057                        McpContentValueSupport.requireWellFormedString(toolRateLimiterName);
1058                        if (toolRateLimiterName.isBlank())
1059                                throw new IllegalArgumentException(
1060                                                "MCP rate-limiter name must not be blank.");
1061                        this.toolRateLimiterName = toolRateLimiterName;
1062                        this.toolRateLimiter = null;
1063                        return this;
1064                }
1065
1066                /**
1067                 * Sets a direct tool-limiter override.
1068                 * <p>
1069                 * Sequential named and direct setter calls are last-call-wins. This call
1070                 * clears any limiter name previously configured on this builder.
1071                 *
1072                 * @param toolRateLimiter direct tool limiter, or null to clear the endpoint
1073                 *                        override
1074                 * @return this builder
1075                 */
1076                @NonNull
1077                public Builder toolRateLimiter(@Nullable McpRateLimiter toolRateLimiter) {
1078                        this.toolRateLimiter = toolRateLimiter;
1079                        this.toolRateLimiterName = null;
1080                        return this;
1081                }
1082
1083                /**
1084                 * Sets the endpoint's subscription-change configuration.
1085                 * <p>
1086                 * Sequential calls are last-call-wins. The immutable configuration and its
1087                 * application-owned publisher are retained by reference.
1088                 *
1089                 * @param subscriptionConfig subscription-change configuration, or null to
1090                 *                           disable subscriptions
1091                 * @return this builder
1092                 */
1093                @NonNull
1094                public Builder subscriptionConfig(
1095                                @Nullable McpSubscriptionConfig subscriptionConfig) {
1096                        this.subscriptionConfig = subscriptionConfig;
1097                        return this;
1098                }
1099
1100                /**
1101                 * Builds an immutable endpoint.
1102                 * <p>
1103                 * No tool, prompt, or resource operation is required. Tool and prompt
1104                 * names must each be unique within the endpoint, as must exact resource
1105                 * URIs and resource URI templates.
1106                 * <p>
1107                 * Every Apps tool resource association must identify an exact registration
1108                 * on this endpoint whose declared MIME type is the Apps HTML profile.
1109                 * Templates and custom resource-list descriptors do not establish this
1110                 * eligibility. Validation does not invoke application handlers or fetch
1111                 * resource contents.
1112                 * <p>Skills names occupy distinct standalone/group listing slots. Registered
1113                 * descendants must be completely present in every enclosing bundle. Shared
1114                 * files must have identical bytes and representation, have at most 16 owners,
1115                 * and cannot overlap ordinary exact-resource or URI-template routes.
1116                 * Individual Skills responses include endpoint metadata in their output
1117                 * preflight. Without a custom Skills-list handler, the complete unfiltered
1118                 * automatic page must fit the output profile and contain at most 32 slots.
1119                 *
1120                 * @return the endpoint
1121                 * @throws IllegalStateException if a tool name, prompt name, exact resource URI,
1122                 *                               or resource URI template is duplicated,
1123                 *                               an Apps association has no eligible resource,
1124                 *                               or Skills configuration conflicts or is incomplete
1125                 * @throws IllegalArgumentException if a Skills response cannot fit the output
1126                 *                                  profile, or the automatic page requires a
1127                 *                                  custom {@code skillListHandler(...)}
1128                 */
1129                @NonNull
1130                public McpEndpoint build() {
1131                        return new McpEndpoint(this);
1132                }
1133        }
1134}