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.ThreadSafe; 023import java.nio.charset.StandardCharsets; 024import java.security.MessageDigest; 025import java.security.NoSuchAlgorithmException; 026import java.util.Base64; 027import java.util.Objects; 028 029import static java.util.Objects.requireNonNull; 030 031/** 032 * Immutable stable identity for one framework-owned localizable MCP field. 033 * <p> 034 * Coordinates contain registration identity and a deterministic semantic 035 * member path, never source or translated text. {@link #toExternalKey()} 036 * returns a versioned representation suitable for an application-owned opaque 037 * translation-key strategy. 038 * 039 * @author <a href="https://www.revetkn.com">Mark Allen</a> 040 */ 041@ThreadSafe 042public final class McpTextCoordinate { 043 private static final byte @NonNull [] EXTERNAL_KEY_DOMAIN = 044 "soklet-mcp-text-coordinate-v1\0" 045 .getBytes(StandardCharsets.US_ASCII); 046 @NonNull 047 private static final String EXTERNAL_KEY_PREFIX = "soklet-mcp-text-v1."; 048 @NonNull 049 private final String endpointPath; 050 @NonNull 051 private final McpTextOwnerType ownerType; 052 @NonNull 053 private final String subjectId; 054 @NonNull 055 private final String memberPath; 056 057 /** 058 * Creates a text coordinate from its structured components. 059 * 060 * @param endpointPath normalized endpoint path 061 * @param ownerType MCP text owner type 062 * @param subjectId stable owner identity within the endpoint 063 * @param memberPath deterministic RFC 6901-escaped semantic member path 064 * @return immutable text coordinate 065 * @throws NullPointerException if any argument is null 066 * @throws IllegalArgumentException if a string argument contains malformed 067 * UTF-16 068 */ 069 @NonNull 070 public static McpTextCoordinate fromComponents( 071 @NonNull String endpointPath, 072 @NonNull McpTextOwnerType ownerType, 073 @NonNull String subjectId, @NonNull String memberPath) { 074 return new McpTextCoordinate(endpointPath, ownerType, subjectId, 075 memberPath); 076 } 077 078 McpTextCoordinate(@NonNull String endpointPath, 079 @NonNull McpTextOwnerType ownerType, 080 @NonNull String subjectId, @NonNull String memberPath) { 081 this.endpointPath = requireValidUnicode(endpointPath, "endpointPath"); 082 this.ownerType = requireNonNull(ownerType); 083 this.subjectId = requireValidUnicode(subjectId, "subjectId"); 084 this.memberPath = requireValidUnicode(memberPath, "memberPath"); 085 } 086 087 /** @return normalized endpoint path */ 088 @NonNull 089 public String getEndpointPath() { 090 return this.endpointPath; 091 } 092 093 /** @return MCP text owner type */ 094 @NonNull 095 public McpTextOwnerType getOwnerType() { 096 return this.ownerType; 097 } 098 099 /** @return stable owner identity within the endpoint */ 100 @NonNull 101 public String getSubjectId() { 102 return this.subjectId; 103 } 104 105 /** @return deterministic RFC 6901-escaped semantic member path */ 106 @NonNull 107 public String getMemberPath() { 108 return this.memberPath; 109 } 110 111 /** 112 * Returns the bounded opaque external key 113 * {@code soklet-mcp-text-v1.<owner-type-token>.<digest>}. The digest is unpadded 114 * Base64URL SHA-256 over the ASCII domain 115 * {@code soklet-mcp-text-coordinate-v1\0}, followed by four UTF-8 components: 116 * endpoint path, fixed lowercase owner-type token, subject ID, and member 117 * path. Each component is preceded by its four-byte unsigned big-endian byte 118 * length. Components are encoded exactly, without normalization or case 119 * folding; malformed UTF-16 is rejected when the coordinate is constructed. 120 * 121 * @return stable versioned opaque translation key 122 */ 123 @NonNull 124 public String toExternalKey() { 125 String ownerTypeToken = ownerTypeToken(this.ownerType); 126 MessageDigest digest = sha256(); 127 digest.update(EXTERNAL_KEY_DOMAIN); 128 updateComponent(digest, this.endpointPath); 129 updateComponent(digest, ownerTypeToken); 130 updateComponent(digest, this.subjectId); 131 updateComponent(digest, this.memberPath); 132 String encodedDigest = Base64.getUrlEncoder().withoutPadding() 133 .encodeToString(digest.digest()); 134 return EXTERNAL_KEY_PREFIX + ownerTypeToken + "." + encodedDigest; 135 } 136 137 @NonNull 138 private static MessageDigest sha256() { 139 try { 140 return MessageDigest.getInstance("SHA-256"); 141 } catch (NoSuchAlgorithmException exception) { 142 throw new ExceptionInInitializerError(exception); 143 } 144 } 145 146 private static void updateComponent(@NonNull MessageDigest digest, 147 @NonNull String value) { 148 byte[] bytes = value.getBytes(StandardCharsets.UTF_8); 149 int length = bytes.length; 150 digest.update((byte) (length >>> 24)); 151 digest.update((byte) (length >>> 16)); 152 digest.update((byte) (length >>> 8)); 153 digest.update((byte) length); 154 digest.update(bytes); 155 } 156 157 @NonNull 158 private static String ownerTypeToken( 159 @NonNull McpTextOwnerType ownerType) { 160 return switch (ownerType) { 161 case SERVER_INFORMATION -> "server-information"; 162 case ENDPOINT -> "endpoint"; 163 case TOOL -> "tool"; 164 case PROMPT -> "prompt"; 165 case RESOURCE -> "resource"; 166 case RESOURCE_TEMPLATE -> "resource-template"; 167 }; 168 } 169 170 @NonNull 171 private static String requireValidUnicode(@NonNull String value, 172 @NonNull String name) { 173 requireNonNull(value, name); 174 for (int index = 0; index < value.length(); ++index) { 175 char character = value.charAt(index); 176 if (Character.isHighSurrogate(character)) { 177 if (++index >= value.length() 178 || !Character.isLowSurrogate(value.charAt(index))) 179 throw new IllegalArgumentException( 180 "MCP text coordinate components must contain valid Unicode."); 181 } else if (Character.isLowSurrogate(character)) { 182 throw new IllegalArgumentException( 183 "MCP text coordinate components must contain valid Unicode."); 184 } 185 } 186 return value; 187 } 188 189 /** @return whether every structured coordinate component is equal */ 190 @Override 191 public boolean equals(@Nullable Object other) { 192 if (this == other) 193 return true; 194 if (!(other instanceof McpTextCoordinate coordinate)) 195 return false; 196 return this.endpointPath.equals(coordinate.endpointPath) 197 && this.ownerType == coordinate.ownerType 198 && this.subjectId.equals(coordinate.subjectId) 199 && this.memberPath.equals(coordinate.memberPath); 200 } 201 202 /** @return value-based hash code */ 203 @Override 204 public int hashCode() { 205 return Objects.hash(this.endpointPath, this.ownerType, 206 this.subjectId, this.memberPath); 207 } 208 209 /** @return coordinate-only rendering that never includes source text */ 210 @Override 211 @NonNull 212 public String toString() { 213 return "McpTextCoordinate{endpointPath=<redacted>, ownerType=" 214 + this.ownerType + ", subjectId=<redacted>, " 215 + "memberPath=<redacted>}"; 216 } 217}