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.NotThreadSafe;
023import javax.annotation.concurrent.ThreadSafe;
024import java.net.URI;
025import java.util.ArrayList;
026import java.util.List;
027import java.util.Objects;
028import java.util.Optional;
029
030import static com.soklet.McpContentValueSupport.annotationsEqual;
031import static com.soklet.McpContentValueSupport.annotationsHashCode;
032import static com.soklet.McpContentValueSupport.iconListHashCode;
033import static com.soklet.McpContentValueSupport.iconListsEqual;
034import static com.soklet.internal.mcp.protocol.McpApplicationMetadata.requireApplicationMetadata;
035import static java.util.Objects.requireNonNull;
036
037/**
038 * Immutable MCP content block linking to a resource.
039 *
040 * <p>When returned as tool content, optional icons are omitted from the
041 * {@code 2025-06-18} wire projection and retained for {@code 2025-11-25} and
042 * the modern revision. Projection does not mutate this value.</p>
043 *
044 * @author <a href="https://www.revetkn.com">Mark Allen</a>
045 */
046@ThreadSafe
047public final class McpResourceLink implements McpContentBlock {
048        @NonNull
049        private final URI uri;
050        @NonNull
051        private final String name;
052        @Nullable
053        private final String title;
054        @Nullable
055        private final String description;
056        @Nullable
057        private final String mimeType;
058        @NonNull
059        private final List<@NonNull McpIcon> icons;
060        @Nullable
061        private final McpContentAnnotations annotations;
062        @Nullable
063        private final Long sizeInBytes;
064        @NonNull
065        private final McpJsonObject metadata;
066
067        /**
068         * Vends a builder primed with the linked resource URI and name.
069         *
070         * @param uri absolute normalized resource URI in ASCII wire form
071         * @param name resource name
072         * @return resource-link builder
073         * @throws IllegalArgumentException if the URI is relative, not normalized,
074         * not in ASCII wire form, or the name is blank
075         */
076        @NonNull
077        public static Builder withUriAndName(@NonNull URI uri,
078                        @NonNull String name) {
079                return new Builder(uri, name);
080        }
081
082        /**
083         * Creates a resource-link content block from an existing concrete resource
084         * descriptor. Every descriptor property is preserved.
085         *
086         * @param resourceDescriptor resource descriptor to copy
087         * @return immutable resource link
088         * @throws NullPointerException if {@code resourceDescriptor} is null
089         */
090        @NonNull
091        public static McpResourceLink fromResourceDescriptor(
092                        @NonNull McpResourceDescriptor resourceDescriptor) {
093                return new McpResourceLink(resourceDescriptor);
094        }
095
096        private McpResourceLink(
097                        @NonNull McpResourceDescriptor resourceDescriptor) {
098                McpResourceDescriptor descriptor = requireNonNull(resourceDescriptor);
099                this.uri = descriptor.getUri();
100                this.name = descriptor.getName();
101                this.title = descriptor.getTitle().orElse(null);
102                this.description = descriptor.getDescription().orElse(null);
103                this.mimeType = descriptor.getMimeType().orElse(null);
104                this.icons = List.copyOf(descriptor.getIcons());
105                this.annotations = descriptor.getAnnotations().orElse(null);
106                this.sizeInBytes = descriptor.getSizeInBytes().orElse(null);
107                this.metadata = descriptor.getMetadata();
108        }
109
110        private McpResourceLink(@NonNull Builder builder) {
111                this.uri = builder.uri;
112                this.name = builder.name;
113                this.title = builder.title;
114                this.description = builder.description;
115                this.mimeType = builder.mimeType;
116                this.icons = List.copyOf(builder.icons);
117                this.annotations = builder.annotations;
118                this.sizeInBytes = builder.sizeInBytes;
119                this.metadata = requireApplicationMetadata(builder.metadata);
120        }
121
122        /** @return absolute normalized linked-resource URI in ASCII wire form */
123        @NonNull
124        public URI getUri() {
125                return this.uri;
126        }
127
128        /** @return linked resource name */
129        @NonNull
130        public String getName() {
131                return this.name;
132        }
133
134        /** @return human-readable title, if supplied */
135        @NonNull
136        public Optional<@NonNull String> getTitle() {
137                return Optional.ofNullable(this.title);
138        }
139
140        /** @return human-readable description, if supplied */
141        @NonNull
142        public Optional<@NonNull String> getDescription() {
143                return Optional.ofNullable(this.description);
144        }
145
146        /** @return resource MIME type, if supplied */
147        @NonNull
148        public Optional<@NonNull String> getMimeType() {
149                return Optional.ofNullable(this.mimeType);
150        }
151
152        /** @return immutable icon list in registration order */
153        @NonNull
154        public List<@NonNull McpIcon> getIcons() {
155                return this.icons;
156        }
157
158        /** @return content annotations, if supplied */
159        @Override
160        @NonNull
161        public Optional<@NonNull McpContentAnnotations> getAnnotations() {
162                return Optional.ofNullable(this.annotations);
163        }
164
165        /** @return resource size in bytes, if supplied */
166        @NonNull
167        public Optional<@NonNull Long> getSizeInBytes() {
168                return Optional.ofNullable(this.sizeInBytes);
169        }
170
171        /** @return immutable extension metadata */
172        @Override
173        @NonNull
174        public McpJsonObject getMetadata() {
175                return this.metadata;
176        }
177
178        /** @return whether every linked-resource property is structurally equal */
179        @Override
180        public boolean equals(@Nullable Object other) {
181                if (this == other)
182                        return true;
183                if (!(other instanceof McpResourceLink link))
184                        return false;
185                return this.uri.equals(link.uri)
186                                && this.name.equals(link.name)
187                                && Objects.equals(this.title, link.title)
188                                && Objects.equals(this.description, link.description)
189                                && Objects.equals(this.mimeType, link.mimeType)
190                                && iconListsEqual(this.icons, link.icons)
191                                && annotationsEqual(this.annotations, link.annotations)
192                                && Objects.equals(this.sizeInBytes, link.sizeInBytes)
193                                && this.metadata.equals(link.metadata);
194        }
195
196        /** @return structural linked-resource hash code */
197        @Override
198        public int hashCode() {
199                int result = Objects.hash(this.uri, this.name, this.title,
200                                this.description, this.mimeType);
201                result = 31 * result + iconListHashCode(this.icons);
202                result = 31 * result + annotationsHashCode(this.annotations);
203                result = 31 * result + Objects.hashCode(this.sizeInBytes);
204                result = 31 * result + this.metadata.hashCode();
205                return result;
206        }
207
208        /**
209         * Mutable builder for immutable {@link McpResourceLink}.
210         *
211         * @author <a href="https://www.revetkn.com">Mark Allen</a>
212         */
213        @NotThreadSafe
214        public static final class Builder {
215                @NonNull
216                private final URI uri;
217                @NonNull
218                private final String name;
219                @Nullable
220                private String title;
221                @Nullable
222                private String description;
223                @Nullable
224                private String mimeType;
225                @NonNull
226                private final List<@NonNull McpIcon> icons = new ArrayList<>();
227                @Nullable
228                private McpContentAnnotations annotations;
229                @Nullable
230                private Long sizeInBytes;
231                @NonNull
232                private McpJsonObject metadata = McpJsonObject.emptyInstance();
233
234                private Builder(@NonNull URI uri, @NonNull String name) {
235                        this.uri = McpResourceValueSupport
236                                        .requireAbsoluteNormalizedUri(uri);
237                        this.name = McpResourceValueSupport.requireNonBlank(name,
238                                        "MCP resource name");
239                }
240
241                /** @param title human-readable title
242                 * @return this builder */
243                @NonNull
244                public Builder title(@NonNull String title) {
245                        this.title = McpContentValueSupport.requireWellFormedString(title);
246                        return this;
247                }
248
249                /** @param description human-readable description
250                 * @return this builder */
251                @NonNull
252                public Builder description(@NonNull String description) {
253                        this.description = McpContentValueSupport.requireWellFormedString(description);
254                        return this;
255                }
256
257                /** @param mimeType syntactically valid resource media type
258                 * @return this builder
259                 * @throws IllegalArgumentException if {@code mimeType} is not a syntactically valid media type
260                 */
261                @NonNull
262                public Builder mimeType(@NonNull String mimeType) {
263                        this.mimeType = McpContentValueSupport.requireMimeType(mimeType);
264                        return this;
265                }
266
267                /**
268                 * Appends an icon.
269                 *
270                 * @param icon icon descriptor
271                 * @return this builder
272                 */
273                @NonNull
274                public Builder addIcon(@NonNull McpIcon icon) {
275                        this.icons.add(requireNonNull(icon));
276                        return this;
277                }
278
279                /** @param annotations content annotations
280                 * @return this builder */
281                @NonNull
282                public Builder annotations(@NonNull McpContentAnnotations annotations) {
283                        this.annotations = requireNonNull(annotations);
284                        return this;
285                }
286
287                /**
288                 * Sets the resource size in bytes.
289                 *
290                 * @param sizeInBytes nonnegative byte count
291                 * @return this builder
292                 * @throws NullPointerException if {@code sizeInBytes} is null
293                 * @throws IllegalArgumentException if {@code sizeInBytes} is negative
294                 */
295                @NonNull
296                public Builder sizeInBytes(@NonNull Long sizeInBytes) {
297                        requireNonNull(sizeInBytes);
298                        if (sizeInBytes < 0)
299                                throw new IllegalArgumentException(
300                                                "Resource size must not be negative.");
301                        this.sizeInBytes = sizeInBytes;
302                        return this;
303                }
304
305                /** @param metadata protocol extension metadata
306                 * @return this builder */
307                @NonNull
308                public Builder metadata(@NonNull McpJsonObject metadata) {
309                        this.metadata = requireNonNull(metadata);
310                        return this;
311                }
312
313                /** @return immutable resource link */
314                @NonNull
315                public McpResourceLink build() {
316                        return new McpResourceLink(this);
317                }
318        }
319}