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}