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.ThreadSafe; 022import java.net.URI; 023import java.util.ArrayList; 024import java.util.HashSet; 025import java.util.List; 026import java.util.Locale; 027import java.util.Objects; 028import java.util.Optional; 029import java.util.Set; 030 031import static java.util.Objects.requireNonNull; 032 033/** 034 * Immutable, explicitly grouped locale alternatives for one Skills name. 035 * 036 * <p>The key is an opaque application-defined, endpoint-local label, not a wire 037 * skill identifier. Grouping grants no access and does not select a language or 038 * translate file bytes. Group different content versions separately instead of 039 * relying on registration order to choose between same-locale versions. 040 * 041 * @author <a href="https://www.revetkn.com">Mark Allen</a> 042 */ 043@ThreadSafe 044public final class McpSkillGroup { 045 @NonNull 046 private final String key; 047 @NonNull 048 private final List<@NonNull McpSkillRegistration> skillRegistrations; 049 050 private McpSkillGroup(@NonNull String key, 051 @NonNull List<@NonNull McpSkillRegistration> skillRegistrations) { 052 this.key = key; 053 this.skillRegistrations = skillRegistrations; 054 } 055 056 /** 057 * Snapshots ordered alternatives sharing one validated skill name. 058 * 059 * <p>An empty group is permitted. Among its members, registration URIs must be 060 * distinct under {@link URI#equals(Object)} and locales must be unique, 061 * including at most one member with an undeclared locale. The key is preserved 062 * literally; it is neither trimmed nor normalized. 063 * 064 * @param key nonblank application-local group label 065 * @param skillRegistrations alternatives in application order, possibly empty 066 * @return immutable group 067 * @throws NullPointerException if the key, list, or a member is null 068 * @throws IllegalArgumentException if the key is blank, member skill names 069 * differ, or member URIs or locales are duplicated 070 */ 071 @NonNull 072 public static McpSkillGroup fromKeyAndSkillRegistrations(@NonNull String key, 073 @NonNull List<@NonNull McpSkillRegistration> skillRegistrations) { 074 requireNonNull(key, "A Skills group key is required."); 075 requireNonNull(skillRegistrations, "Skills group registrations are required."); 076 if (key.isBlank()) throw invalid(); 077 List<McpSkillRegistration> snapshot = new ArrayList<>(); 078 Set<URI> uris = new HashSet<>(); 079 Set<Optional<Locale>> locales = new HashSet<>(); 080 String name = null; 081 for (McpSkillRegistration registration : skillRegistrations) { 082 requireNonNull(registration, "A Skills group registration is required."); 083 String memberName = registration.getSkillBundle().getName(); 084 if (name == null) name = memberName; 085 else if (!name.equals(memberName)) throw invalid(); 086 if (!uris.add(registration.getUri()) || !locales.add(registration.getLocale())) throw invalid(); 087 snapshot.add(registration); 088 } 089 return new McpSkillGroup(key, List.copyOf(snapshot)); 090 } 091 092 /** @return exact application-defined group label */ 093 @NonNull 094 public String getKey() { return this.key; } 095 096 /** @return immutable alternatives in the supplied order */ 097 @NonNull 098 public List<@NonNull McpSkillRegistration> getSkillRegistrations() { return this.skillRegistrations; } 099 100 /** @return whether the exact key and ordered registration values match */ 101 @Override 102 public boolean equals(@Nullable Object other) { 103 return this == other || other instanceof McpSkillGroup group 104 && this.key.equals(group.key) && this.skillRegistrations.equals(group.skillRegistrations); 105 } 106 107 /** @return structural group hash code */ 108 @Override 109 public int hashCode() { return Objects.hash(this.key, this.skillRegistrations); } 110 111 /** @return diagnostic rendering without group labels or registrations */ 112 @Override 113 @NonNull 114 public String toString() { return "McpSkillGroup[redacted]"; } 115 116 private static @NonNull IllegalArgumentException invalid() { 117 return new IllegalArgumentException("Invalid Skills group."); 118 } 119}