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}