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 * https://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 */
016package com.soklet;
017
018import org.jspecify.annotations.NonNull;
019import org.jspecify.annotations.Nullable;
020
021import javax.annotation.concurrent.NotThreadSafe;
022import javax.annotation.concurrent.ThreadSafe;
023import java.time.Duration;
024import java.util.List;
025import java.util.Objects;
026import java.util.Optional;
027
028import static java.util.Objects.requireNonNull;
029import static com.soklet.internal.mcp.protocol.McpApplicationMetadata.requireApplicationMetadata;
030
031/**
032 * One immutable complete {@code skills/list} page containing at most 32 skills.
033 *
034 * <p>Return this result directly, without a {@link McpCompleteResult} wrapper.
035 * Registrations reference complete manifests; a skill is never split between
036 * pages. Soklet derives descriptions and content identities from the registered
037 * bundles rather than accepting replacement metadata or hashes from a handler.
038 *
039 * <p>The endpoint's Skills-list cache policy fixes scope and default freshness.
040 * A page may override only the time to live, subject to the framework's
041 * localization and security clamps. Result-level extension metadata does not
042 * replace a bundle's frontmatter.
043 *
044 * @author <a href="https://www.revetkn.com">Mark Allen</a>
045 */
046@ThreadSafe
047public final class McpSkillPage implements McpOperationResult {
048        @NonNull
049        private final List<@NonNull McpSkillRegistration> skillRegistrations;
050        @NonNull
051        private final McpJsonObject metadata;
052        @Nullable
053        private final String nextCursor;
054        @Nullable
055        private final Duration cacheTimeToLiveOverride;
056
057        /** @return an empty Skills-page builder */
058        @NonNull
059        public static Builder builder() { return new Builder(); }
060
061        private McpSkillPage(@NonNull Builder builder) {
062                this.skillRegistrations = builder.skillRegistrations;
063                this.metadata = requireApplicationMetadata(builder.metadata);
064                this.nextCursor = builder.nextCursor;
065                this.cacheTimeToLiveOverride = builder.cacheTimeToLiveOverride;
066        }
067
068        /** @return immutable registration references in supplied order */
069        @NonNull
070        public List<@NonNull McpSkillRegistration> getSkillRegistrations() { return this.skillRegistrations; }
071
072        /** @return immutable result-level protocol extension metadata */
073        @NonNull
074        public McpJsonObject getMetadata() { return this.metadata; }
075
076        /**
077         * Returns the opaque cursor clients should supply for the next page.
078         * The application owns its integrity, authorization binding, expiry, page
079         * position, retained snapshot, content identities, and cross-node portability.
080         * A present empty string is preserved; Soklet does not mint or protect cursors.
081         *
082         * @return next cursor, or empty when this is the final page
083         */
084        @NonNull
085        public Optional<@NonNull String> getNextCursor() { return Optional.ofNullable(this.nextCursor); }
086
087        /** @return freshness override, or empty to use the endpoint default */
088        @NonNull
089        public Optional<@NonNull Duration> getCacheTimeToLiveOverride() {
090                return Optional.ofNullable(this.cacheTimeToLiveOverride);
091        }
092
093        /** @return whether every page property is structurally equal */
094        @Override
095        public boolean equals(@Nullable Object other) {
096                return this == other || other instanceof McpSkillPage page
097                                && this.skillRegistrations.equals(page.skillRegistrations)
098                                && this.metadata.equals(page.metadata)
099                                && Objects.equals(this.nextCursor, page.nextCursor)
100                                && Objects.equals(this.cacheTimeToLiveOverride, page.cacheTimeToLiveOverride);
101        }
102
103        /** @return structural page hash code */
104        @Override
105        public int hashCode() {
106                return Objects.hash(this.skillRegistrations, this.metadata, this.nextCursor, this.cacheTimeToLiveOverride);
107        }
108
109        /** @return diagnostic rendering without registrations, metadata, or cursor data */
110        @Override
111        @NonNull
112        public String toString() { return "McpSkillPage[redacted]"; }
113
114        /**
115         * Single-threaded builder for an immutable Skills page.
116         *
117         * @author <a href="https://www.revetkn.com">Mark Allen</a>
118         */
119        @NotThreadSafe
120        public static final class Builder {
121                @NonNull
122                private List<@NonNull McpSkillRegistration> skillRegistrations = List.of();
123                @NonNull
124                private McpJsonObject metadata = McpJsonObject.emptyInstance();
125                @Nullable
126                private String nextCursor;
127                @Nullable
128                private Duration cacheTimeToLiveOverride;
129
130                private Builder() {}
131
132                /**
133                 * Replaces registration references in supplied order. Null or empty clears
134                 * the property. The complete list is snapshotted before replacing its prior
135                 * value. Runtime validation requires exact configured registration instances,
136                 * without duplicate URIs, and enforces current access and page-phase rules.
137                 *
138                 * @param skillRegistrations registration references, or null to clear
139                 * @return this builder
140                 * @throws NullPointerException if a list element is null
141                 */
142                @NonNull
143                public Builder skillRegistrations(@Nullable List<@NonNull McpSkillRegistration> skillRegistrations) {
144                        this.skillRegistrations = skillRegistrations == null ? List.of() : List.copyOf(skillRegistrations);
145                        return this;
146                }
147
148                /**
149                 * Sets result-level protocol extension metadata, not skill frontmatter.
150                 *
151                 * @param metadata immutable result-level protocol extension metadata
152                 * @return this builder
153                 * @throws NullPointerException if metadata is null
154                 */
155                @NonNull
156                public Builder metadata(@NonNull McpJsonObject metadata) {
157                        this.metadata = requireNonNull(metadata);
158                        return this;
159                }
160
161                /**
162                 * Supplies an application-owned opaque continuation cursor. Empty strings
163                 * remain present values; no trimming or normalization occurs.
164                 *
165                 * @param nextCursor next cursor
166                 * @return this builder
167                 * @throws IllegalArgumentException if the cursor contains malformed UTF-16
168                 * @throws NullPointerException if the cursor is null
169                 */
170                @NonNull
171                public Builder nextCursor(@NonNull String nextCursor) {
172                        this.nextCursor = McpContentValueSupport.requireWellFormedString(requireNonNull(nextCursor));
173                        return this;
174                }
175
176                /**
177                 * Overrides default freshness without changing cache scope or bypassing
178                 * localization and security clamps.
179                 *
180                 * @param cacheTimeToLiveOverride nonnegative whole-millisecond duration
181                 * @return this builder
182                 * @throws NullPointerException if the duration is null
183                 * @throws IllegalArgumentException if negative, sub-millisecond, or too
184                 * large for a signed 64-bit millisecond value
185                 */
186                @NonNull
187                public Builder cacheTimeToLiveOverride(@NonNull Duration cacheTimeToLiveOverride) {
188                        this.cacheTimeToLiveOverride = McpCachePolicy.requireTimeToLive(cacheTimeToLiveOverride);
189                        return this;
190                }
191
192                /**
193                 * Builds one complete immutable page.
194                 *
195                 * @return immutable Skills page
196                 * @throws IllegalStateException if the page contains more than 32 registrations
197                 * @throws IllegalArgumentException if application metadata uses a reserved framework key
198                 */
199                @NonNull
200                public McpSkillPage build() {
201                        if (this.skillRegistrations.size() > 32)
202                                throw new IllegalStateException("An MCP Skills page may contain at most 32 registrations.");
203                        return new McpSkillPage(this);
204                }
205        }
206}