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.util.Locale;
025
026import static java.util.Objects.requireNonNull;
027
028/**
029 * Immutable server-wide MCP localization behavior and policy.
030 * <p>
031 * The configured fallback locale is the language of every canonical
032 * annotation and builder string hosted by the server and the locale of
033 * canonical default text. It is independent of a provider or localization
034 * library's terminal fallback locale. The application-owned context provider
035 * performs request-specific locale selection and fallback resolution and
036 * captures one immutable translation snapshot.
037 *
038 * @author <a href="https://www.revetkn.com">Mark Allen</a>
039 */
040@ThreadSafe
041public final class McpLocalizer {
042        private static final int DEFAULT_MAXIMUM_LOCALIZABLE_TEXT_COUNT_PER_RESPONSE =
043                        32_768;
044        private static final int MAXIMUM_SUPPORTED_LOCALIZABLE_TEXT_COUNT_PER_RESPONSE =
045                        100_000;
046        @NonNull
047        private final Locale fallbackLocale;
048        @NonNull
049        private final McpLocalizationContextProvider contextProvider;
050        @NonNull
051        private final McpLocalizationFailurePolicy failurePolicy;
052        private final int maximumLocalizableTextCountPerResponse;
053
054        /**
055         * Vends a builder primed with the canonical non-root fallback locale and
056         * application-owned context provider.
057         *
058         * @param fallbackLocale canonical BCP 47 fallback locale
059         * @param localizationContextProvider request context provider
060         * @return localizer builder
061         * @throws NullPointerException if an argument is null
062         * @throws IllegalArgumentException if {@code fallbackLocale} is not a
063         * canonical, non-root BCP 47 locale of at most 255 ASCII bytes
064         */
065        @NonNull
066        public static Builder withFallbackLocale(@NonNull Locale fallbackLocale,
067                        @NonNull McpLocalizationContextProvider
068                                        localizationContextProvider) {
069                Locale requiredFallbackLocale =
070                                McpLocaleSupport.requireCanonicalCatalogLocale(
071                                                fallbackLocale, "fallbackLocale");
072                return new Builder(requiredFallbackLocale, localizationContextProvider);
073        }
074
075        private McpLocalizer(@NonNull Builder builder) {
076                this.fallbackLocale = builder.fallbackLocale;
077                this.contextProvider = builder.contextProvider;
078                this.failurePolicy = builder.failurePolicy;
079                this.maximumLocalizableTextCountPerResponse =
080                                builder.maximumLocalizableTextCountPerResponse;
081        }
082
083        /** @return configured canonical non-root fallback locale */
084        @NonNull
085        public Locale getFallbackLocale() {
086                return this.fallbackLocale;
087        }
088
089        /** @return application-owned request context provider */
090        @NonNull
091        public McpLocalizationContextProvider getContextProvider() {
092                return this.contextProvider;
093        }
094
095        /**
096         * Returns the whole-response framework-catalog failure policy. It does not
097         * authorize a synthetic context when application context creation fails.
098         *
099         * @return framework-catalog localization failure policy
100         */
101        @NonNull
102        public McpLocalizationFailurePolicy getFailurePolicy() {
103                return this.failurePolicy;
104        }
105
106        /**
107         * Returns the maximum number of provider text lookups permitted while
108         * rendering one framework response.
109         *
110         * @return positive bounded response-local lookup limit
111         */
112        @NonNull
113        public Integer getMaximumLocalizableTextCountPerResponse() {
114                return this.maximumLocalizableTextCountPerResponse;
115        }
116
117        /**
118         * Single-threaded builder for an immutable localizer.
119         *
120         * @author <a href="https://www.revetkn.com">Mark Allen</a>
121         */
122        @NotThreadSafe
123        public static final class Builder {
124                @NonNull
125                private final Locale fallbackLocale;
126                @NonNull
127                private final McpLocalizationContextProvider contextProvider;
128                @NonNull
129                private McpLocalizationFailurePolicy failurePolicy;
130                private int maximumLocalizableTextCountPerResponse;
131
132                private Builder(@NonNull Locale fallbackLocale,
133                                @NonNull McpLocalizationContextProvider
134                                                localizationContextProvider) {
135                        this.fallbackLocale = fallbackLocale;
136                        this.contextProvider = requireNonNull(localizationContextProvider);
137                        this.failurePolicy =
138                                        McpLocalizationFailurePolicy.USE_DEFAULT_TEXT;
139                        this.maximumLocalizableTextCountPerResponse =
140                                        DEFAULT_MAXIMUM_LOCALIZABLE_TEXT_COUNT_PER_RESPONSE;
141                }
142
143                /**
144                 * Sets the whole-response behavior for unexpected framework-catalog
145                 * provider or replacement validation failure. The default uses canonical
146                 * fallback text. Application context-creation failure remains a sanitized
147                 * failure before handler/interceptor entry under either policy.
148                 *
149                 * @param failurePolicy failure policy, or null to restore the default
150                 * @return this builder
151                 */
152                @NonNull
153                public Builder failurePolicy(
154                                @Nullable McpLocalizationFailurePolicy failurePolicy) {
155                        this.failurePolicy = failurePolicy == null
156                                        ? McpLocalizationFailurePolicy.USE_DEFAULT_TEXT
157                                        : failurePolicy;
158                        return this;
159                }
160
161                /**
162                 * Sets the maximum provider lookup count for one framework response.
163                 * The default is 32,768 and the supported range is 1 through 100,000.
164                 *
165                 * @param maximumLocalizableTextCountPerResponse positive bounded limit, or
166                 *                                               null to restore the default
167                 * @return this builder
168                 * @throws IllegalArgumentException if the value is outside the supported
169                 * range
170                 */
171                @NonNull
172                public Builder maximumLocalizableTextCountPerResponse(
173                                @Nullable Integer maximumLocalizableTextCountPerResponse) {
174                        if (maximumLocalizableTextCountPerResponse == null) {
175                                this.maximumLocalizableTextCountPerResponse =
176                                                DEFAULT_MAXIMUM_LOCALIZABLE_TEXT_COUNT_PER_RESPONSE;
177                                return this;
178                        }
179                        int requiredMaximum = maximumLocalizableTextCountPerResponse;
180                        if (requiredMaximum < 1
181                                        || requiredMaximum
182                                        > MAXIMUM_SUPPORTED_LOCALIZABLE_TEXT_COUNT_PER_RESPONSE)
183                                throw new IllegalArgumentException(
184                                                "Maximum localizable text count per response must be "
185                                                                + "between 1 and 100000.");
186                        this.maximumLocalizableTextCountPerResponse = requiredMaximum;
187                        return this;
188                }
189
190                /** @return immutable server localizer */
191                @NonNull
192                public McpLocalizer build() {
193                        return new McpLocalizer(this);
194                }
195        }
196}