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}