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.List;
026import java.util.Objects;
027import java.util.Optional;
028
029import static java.util.Objects.requireNonNull;
030
031/**
032 * Immutable icon descriptor for an MCP tool, prompt, or resource.
033 *
034 * <p>The source must be absolute and contain well-formed UTF-16. Its spelling
035 * is retained without URI normalization or a scheme allowlist. MIME types use
036 * the same ASCII media-type syntax parser as MCP content. Size hints must use
037 * ASCII decimal {@code WxH} dimensions with a lowercase {@code x}, or exactly
038 * {@code any}; order, duplicates, and decimal spelling are retained.</p>
039 *
040 * <p>Soklet does not fetch icons, verify dimensions or media bytes, establish
041 * trusted source domains, or sanitize SVG. Applications and consuming clients
042 * own source and rendering policy; prefer trusted HTTPS sources or image data
043 * URIs. Construction does not prevalidate the eventual JSON response size.</p>
044 *
045 * @author <a href="https://www.revetkn.com">Mark Allen</a>
046 */
047@ThreadSafe
048public final class McpIcon {
049        @NonNull
050        private final URI source;
051        @Nullable
052        private final String mimeType;
053        @NonNull
054        private final List<@NonNull String> sizes;
055        @Nullable
056        private final McpIconTheme theme;
057
058        /**
059         * Vends a builder primed with the icon source URI.
060         *
061         * @param source absolute icon source URI
062         * @return icon builder
063         * @throws IllegalArgumentException if the source is relative or contains an unpaired surrogate
064         */
065        @NonNull
066        public static Builder withSource(@NonNull URI source) {
067                return new Builder(source);
068        }
069
070        private McpIcon(@NonNull Builder builder) {
071                this.source = builder.source;
072                this.mimeType = builder.mimeType;
073                this.sizes = List.copyOf(builder.sizes);
074                this.theme = builder.theme;
075        }
076
077        @NonNull
078        private static URI requireSource(@NonNull URI source) {
079                requireNonNull(source);
080                if (!source.isAbsolute())
081                        throw new IllegalArgumentException("MCP icon sources must be absolute URIs.");
082                McpContentValueSupport.requireWellFormedString(source.toString());
083                return source;
084        }
085
086        private static void requireSize(@NonNull String size) {
087                requireNonNull(size);
088                if (size.equals("any"))
089                        return;
090                int separator = size.indexOf('x');
091                if (separator <= 0 || separator == size.length() - 1)
092                        throw invalidSize();
093                for (int index = 0; index < size.length(); ++index) {
094                        if (index == separator)
095                                continue;
096                        char character = size.charAt(index);
097                        if (character < '0' || character > '9')
098                                throw invalidSize();
099                }
100        }
101
102        @NonNull
103        private static IllegalArgumentException invalidSize() {
104                return new IllegalArgumentException(
105                                "MCP icon sizes must use ASCII WxH dimensions or 'any'.");
106        }
107
108        /** @return icon source URI */
109        @NonNull
110        public URI getSource() {
111                return this.source;
112        }
113
114        /** @return MIME type, if supplied */
115        @NonNull
116        public Optional<@NonNull String> getMimeType() {
117                return Optional.ofNullable(this.mimeType);
118        }
119
120        /** @return immutable advertised icon sizes */
121        @NonNull
122        public List<@NonNull String> getSizes() {
123                return this.sizes;
124        }
125
126        /** @return preferred theme, if supplied */
127        @NonNull
128        public Optional<@NonNull McpIconTheme> getTheme() {
129                return Optional.ofNullable(this.theme);
130        }
131
132        /** @return whether every icon property is structurally equal */
133        @Override
134        public boolean equals(@Nullable Object other) {
135                if (this == other)
136                        return true;
137                if (!(other instanceof McpIcon icon))
138                        return false;
139                return this.source.equals(icon.source)
140                                && Objects.equals(this.mimeType, icon.mimeType)
141                                && this.sizes.equals(icon.sizes)
142                                && Objects.equals(this.theme, icon.theme);
143        }
144
145        /** @return structural icon hash code */
146        @Override
147        public int hashCode() {
148                return Objects.hash(this.source, this.mimeType, this.sizes, this.theme);
149        }
150
151        /**
152         * Mutable builder for an immutable {@link McpIcon}.
153         *
154         * @author <a href="https://www.revetkn.com">Mark Allen</a>
155         */
156        @NotThreadSafe
157        public static final class Builder {
158                @NonNull
159                private final URI source;
160                @Nullable
161                private String mimeType;
162                @NonNull
163                private List<@NonNull String> sizes = List.of();
164                @Nullable
165                private McpIconTheme theme;
166
167                private Builder(@NonNull URI source) {
168                        this.source = requireSource(source);
169                }
170
171                /**
172                 * Sets the icon MIME type.
173                 *
174                 * @param mimeType MIME type
175                 * @return this builder
176                 * @throws IllegalArgumentException if the MIME type has malformed syntax
177                 */
178                @NonNull
179                public Builder mimeType(@NonNull String mimeType) {
180                        this.mimeType = McpContentValueSupport.requireMimeType(mimeType);
181                        return this;
182                }
183
184                /**
185                 * Replaces advertised icon sizes in supplied order.
186                 * Null or empty clears the property. The complete list is validated and
187                 * snapshotted before replacing the prior value.
188                 *
189                 * @param sizes ASCII decimal WxH dimensions or {@code any}, or null to clear
190                 * @return this builder
191                 * @throws IllegalArgumentException if a size has malformed syntax
192                 * @throws NullPointerException if a list element is null
193                 */
194                @NonNull
195                public Builder sizes(
196                                @Nullable List<@NonNull String> sizes) {
197                        List<String> replacement = sizes == null ? List.of() : List.copyOf(sizes);
198                        replacement.forEach(McpIcon::requireSize);
199                        this.sizes = replacement;
200                        return this;
201                }
202
203                /**
204                 * Sets the preferred icon theme.
205                 *
206                 * @param theme icon theme
207                 * @return this builder
208                 */
209                @NonNull
210                public Builder theme(@NonNull McpIconTheme theme) {
211                        this.theme = requireNonNull(theme);
212                        return this;
213                }
214
215                /** @return immutable icon descriptor */
216                @NonNull
217                public McpIcon build() {
218                        return new McpIcon(this);
219                }
220        }
221}