Model Context Protocol
MCP Localization
Soklet can localize the human-readable text it owns in MCP catalogs - server title and description, endpoint instructions, tool and prompt titles and descriptions, prompt argument text, resource and template titles and descriptions, and nested input/output schema title/description annotations - selected per request. Registrations, routing, identifiers, and validation schemas stay canonical; application result text is application-owned.
Soklet has no dependency on any translation library. You supply a context provider; Soklet supplies the request, the bounded client preferences, the extracted text coordinates, and the immutable context value. The provider builds that value with the selected locale and a lookup callback over its captured catalog snapshot, so applications do not implement a throwaway context type.
Protocol-specific server text
On 2026-07-28 endpoints, framework-generated tools/list, prompts/list, static resources/list, and resources/templates/list localize the title and description in included _meta["io.modelcontextprotocol/serverInfo"]. This includes empty or caller-filtered catalogs and catalogs without translatable descriptor text. These fields reuse the canonical server coordinates, count toward the same response-wide lookup and byte limits, and follow the whole-response failure policy. Server name and version stay canonical. The 2025 catalog projections omit this metadata and do not perform its lookups.
Custom resources/list results and other application results are not post-processed. Their appended server-information metadata retains canonical text. Handlers use the selected localization context to translate their own result text.
For explicitly enabled 2025-06-18 and 2025-11-25 endpoints, Soklet localizes the server title and endpoint instructions in initialize. The November view also includes the localized server description; the June view omits that field and does not perform its lookup. These fields use the same canonical text coordinates as modern discovery. The required initialization serverInfo remains present even when optional server-information result metadata is disabled.
Initialization follows the same response-wide lookup limits, byte limits, and failure policy as other framework catalog responses. A successful render emits Content-Language; whole-response USE_DEFAULT_TEXT fallback emits the configured fallback locale. FAIL_REQUEST returns the fixed internal error and releases the pending session without returning its ID, allowing another initialization attempt.
Enabling localization
Add localization to a fresh builder carrying your local MCP server configuration or your application's production settings:
McpLocalizationContextProvider contextProvider =
new HardcodedMcpLocalizationProvider();
McpLocalizer localizer = McpLocalizer.withFallbackLocale(
Locale.ENGLISH, contextProvider)
.build();
McpServer server = mcpServerBuilder
.localizer(localizer)
.build();
McpLocalizationContextProvider contextProvider =
new HardcodedMcpLocalizationProvider();
McpLocalizer localizer = McpLocalizer.withFallbackLocale(
Locale.ENGLISH, contextProvider)
.build();
McpServer server = mcpServerBuilder
.localizer(localizer)
.build();
Omitting localizer(...) preserves every existing byte on the wire. The fallback locale names the language your canonical annotation and builder strings are already written in.
A small hardcoded provider
Suppose the canonical text in your annotations and builders is English, and the only additional catalog is Brazilian Portuguese. For a small service or an integration test, the complete provider can be little more than locale selection plus an immutable map:
final class HardcodedMcpLocalizationProvider
implements McpLocalizationContextProvider {
private static final Locale ENGLISH = Locale.ENGLISH;
private static final Locale PORTUGUESE =
Locale.forLanguageTag("pt-BR");
private static final List<Locale> SUPPORTED_LOCALES =
List.of(PORTUGUESE, ENGLISH);
// These keys are the exact canonical strings declared by the endpoint.
private static final Map<String, String> PORTUGUESE_TEXT = Map.of(
"Toy Store MCP", "MCP da Loja de Brinquedos",
"List toys", "Listar brinquedos",
"Gets a toy by its ID.",
"Obtém um brinquedo pelo identificador."
);
@Override
public McpLocalizationContext provideContext(
McpLocalizationRequest request) {
Locale locale = request.getContinuationLocale()
.orElseGet(() -> {
Locale match = Locale.lookup(
request.getLanguageRanges(), SUPPORTED_LOCALES);
return match == null ? ENGLISH : match;
});
return McpLocalizationContext.withLocale(locale, text -> {
if (!PORTUGUESE.equals(locale))
return McpLocalizationResult.useDefaultText();
String translation =
PORTUGUESE_TEXT.get(text.getDefaultText());
return translation == null
? McpLocalizationResult.useDefaultText()
: McpLocalizationResult.localized(translation);
})
.build();
}
}
final class HardcodedMcpLocalizationProvider
implements McpLocalizationContextProvider {
private static final Locale ENGLISH = Locale.ENGLISH;
private static final Locale PORTUGUESE =
Locale.forLanguageTag("pt-BR");
private static final List<Locale> SUPPORTED_LOCALES =
List.of(PORTUGUESE, ENGLISH);
// These keys are the exact canonical strings declared by the endpoint.
private static final Map<String, String> PORTUGUESE_TEXT = Map.of(
"Toy Store MCP", "MCP da Loja de Brinquedos",
"List toys", "Listar brinquedos",
"Gets a toy by its ID.",
"Obtém um brinquedo pelo identificador."
);
@Override
public McpLocalizationContext provideContext(
McpLocalizationRequest request) {
Locale locale = request.getContinuationLocale()
.orElseGet(() -> {
Locale match = Locale.lookup(
request.getLanguageRanges(), SUPPORTED_LOCALES);
return match == null ? ENGLISH : match;
});
return McpLocalizationContext.withLocale(locale, text -> {
if (!PORTUGUESE.equals(locale))
return McpLocalizationResult.useDefaultText();
String translation =
PORTUGUESE_TEXT.get(text.getDefaultText());
return translation == null
? McpLocalizationResult.useDefaultText()
: McpLocalizationResult.localized(translation);
})
.build();
}
}
For Accept-Language: pt-BR, en;q=0.8, this context reports pt-BR and translates every key present in PORTUGUESE_TEXT. A missing key returns useDefaultText(), which intentionally publishes the canonical English text; it is not an operational failure. A request with no Portuguese match reports English and uses canonical text throughout. A verified multi-round continuation bypasses fresh negotiation and keeps its original locale.
This source-text-key approach is intentionally simple. Keep the map immutable, do not perform file or network I/O from localize(...), and use failure() only for an unexpected lookup failure rather than an ordinary missing translation.
McpLocalizationResult owns construction for all three outcomes: localized(text), useDefaultText(), and failure(). The nested Localized, UseDefaultText, and Failure types are public final variants with private constructors, so tests and adapters can use typed pattern matching without making their construction part of the variant classes. A matched Localized exposes its nonblank value through getText(); the two fieldless outcomes expose no payload getter.
How you might integrate Lokalized
Hardcoded maps become awkward once you need translation files, locale fallback, placeholders, grammatical alternatives, bidirectional isolation, validation, and atomic reloads. Lokalized is a zero-dependency Java localization library that supplies those pieces while Soklet remains library-neutral.
With the same source-text key strategy, a classpath resource named strings/pt-BR can contain:
{
"Toy Store MCP": "MCP da Loja de Brinquedos",
"List toys": "Listar brinquedos",
"Gets a toy by its ID.": "Obtém um brinquedo pelo identificador."
}
{
"Toy Store MCP": "MCP da Loja de Brinquedos",
"List toys": "Listar brinquedos",
"Gets a toy by its ID.": "Obtém um brinquedo pelo identificador."
}
Adapt an immutable Strings graph to Soklet's request-scoped contract. The following is illustrative application code kept here in the documentation; LokalizedMcpLocalizationContextProvider is not a Soklet type or a maintained adapter shipped by either project.
final class LokalizedMcpLocalizationContextProvider
implements McpLocalizationContextProvider {
private final AtomicReference<Strings> current;
LokalizedMcpLocalizationContextProvider(Strings initial) {
current = new AtomicReference<>(initial);
}
void install(Strings replacement) {
current.set(replacement);
}
@Override
public McpLocalizationContext provideContext(
McpLocalizationRequest request) {
// One read gives the entire response one immutable catalog snapshot.
Strings snapshot = current.get();
Locale locale = request.getContinuationLocale()
.orElseGet(() -> snapshot.bestMatchFor(
request.getLanguageRanges()));
TranslationOptions options = TranslationOptions.builder()
.locale(locale)
.translationFailureHandler(
TranslationFailureHandler.returnKey())
.translationFallbackPolicy(TranslationFallbackPolicy
.fallbackOnMissingTranslationOrNoMatchingAlternative())
.build();
return McpLocalizationContext.withLocale(locale, text -> {
try {
TranslationResult result = snapshot.getResult(
text.getDefaultText(), options);
if (result.getStatus()
== TranslationResultStatus.TRANSLATED)
return McpLocalizationResult.localized(
result.getTranslation());
return switch (result.getFailureReason().orElseThrow()) {
case MISSING_TRANSLATION, NO_MATCHING_ALTERNATIVE ->
McpLocalizationResult.useDefaultText();
case RESOLUTION_FAILURE ->
McpLocalizationResult.failure();
};
} catch (RuntimeException exception) {
return McpLocalizationResult.failure();
}
})
.build();
}
}
final class LokalizedMcpLocalizationContextProvider
implements McpLocalizationContextProvider {
private final AtomicReference<Strings> current;
LokalizedMcpLocalizationContextProvider(Strings initial) {
current = new AtomicReference<>(initial);
}
void install(Strings replacement) {
current.set(replacement);
}
@Override
public McpLocalizationContext provideContext(
McpLocalizationRequest request) {
// One read gives the entire response one immutable catalog snapshot.
Strings snapshot = current.get();
Locale locale = request.getContinuationLocale()
.orElseGet(() -> snapshot.bestMatchFor(
request.getLanguageRanges()));
TranslationOptions options = TranslationOptions.builder()
.locale(locale)
.translationFailureHandler(
TranslationFailureHandler.returnKey())
.translationFallbackPolicy(TranslationFallbackPolicy
.fallbackOnMissingTranslationOrNoMatchingAlternative())
.build();
return McpLocalizationContext.withLocale(locale, text -> {
try {
TranslationResult result = snapshot.getResult(
text.getDefaultText(), options);
if (result.getStatus()
== TranslationResultStatus.TRANSLATED)
return McpLocalizationResult.localized(
result.getTranslation());
return switch (result.getFailureReason().orElseThrow()) {
case MISSING_TRANSLATION, NO_MATCHING_ALTERNATIVE ->
McpLocalizationResult.useDefaultText();
case RESOLUTION_FAILURE ->
McpLocalizationResult.failure();
};
} catch (RuntimeException exception) {
return McpLocalizationResult.failure();
}
})
.build();
}
}
Build the catalog once during application setup, then pass this provider to the same McpLocalizer builder shown above:
Strings strings = Strings.withFallbackLocale(Locale.ENGLISH)
.localizedStringSupplier(() ->
LocalizedStringLoader.loadFromClasspath("strings"))
// Default for ordinary Strings calls; the adapter supplies an
// explicit request locale for every MCP lookup.
.localeSupplier(matcher ->
matcher.bestMatchFor(Locale.ENGLISH))
.build();
var contextProvider =
new LokalizedMcpLocalizationContextProvider(strings);
Strings strings = Strings.withFallbackLocale(Locale.ENGLISH)
.localizedStringSupplier(() ->
LocalizedStringLoader.loadFromClasspath("strings"))
// Default for ordinary Strings calls; the adapter supplies an
// explicit request locale for every MCP lookup.
.localeSupplier(matcher ->
matcher.bestMatchFor(Locale.ENGLISH))
.build();
var contextProvider =
new LokalizedMcpLocalizationContextProvider(strings);
The builder is Soklet-owned; the application supplies only the selected locale and the lookup lambda. That lambda closes over snapshot and options, so the whole response still observes one immutable catalog generation without a custom context implementation.
The important boundary is the result mapping: every successful translation is localized(text), including one that Lokalized found through its own parent or terminal fallback graph. Soklet does not require the adapter to expose which catalog locale supplied an individual field. An ordinary missing translation is useDefaultText(), and an unexpected resolution problem is failure(). The example uses canonical source text as its key; a coordinate-keyed catalog would call text.getCoordinate() and then toExternalKey() instead.
For a catalog reload, build and validate a new Strings graph off the request path, atomically install it, and then call server.getLocalizationCatalogInvalidator().invalidateCatalogs() on each applicable instance. Existing contexts retain their old immutable snapshot; new requests see the new one. If catalogs can remove locales, reject a continuation whose required locale is no longer supported instead of silently renegotiating it. The adapter above is illustrative application code, not a maintained Soklet or Lokalized integration module. The Toy Store application demonstrates the same boundary in a real downstream Soklet integration.
What the provider receives
McpLocalizationRequest carries the admitted request context, the bounded client preference view, a verified continuation locale when one applies, and the opaque custom resources/list cursor.
Preferences are bounded before your code runs: at most 4,096 UTF-16 code units of combined Accept-Language and at most 32 parsed ranges after JDK alias expansion. Nothing is truncated - missing, blank, malformed, or over-limit input becomes an empty list, which reaches your own fallback behavior. Zero-weight (q=0) exclusions are preserved in wire order. Matching policy is yours: RFC lookup, a richer matcher, or your own tenant policy.
Within the raw-input bound, Soklet ignores empty comma-separated elements and normalizes SP/HTAB at element edges and around the quality-value semicolon before JDK parsing, following the HTTP list and language preference rules. Empty elements still count toward that bound. Physical header order and the first occurrence of a duplicate range are retained; malformed tokens or remaining controls cause the whole preference view to fall back to an empty list.
Returning translations
localize(...) is called once per planned text and returns one of three results:
| Result | Meaning |
|---|---|
localized(text) | Successfully selected localized text, including provider-managed fallback. |
useDefaultText() | Intentionally untranslated; this field publishes canonical text. |
failure() | Unexpected lookup failure; takes the whole-response failure path. |
localized(text) describes a successful context lookup, not a claim that a particular catalog file supplied the value. This keeps adapters compatible with localization APIs that return only text and hide their fallback traversal. If your application needs per-message provenance for its own diagnostics, keep that inside the provider; Soklet does not use it to render the response.
Rendering is fail-atomic: clients never receive a half-localized response. If translation fails, is canceled, exceeds the request deadline, or exceeds the response-size budget, Soklet applies the selected failure policy to the whole response.
McpLocalizationFailurePolicy chooses what happens then: USE_DEFAULT_TEXT publishes canonical source text, FAIL_REQUEST returns one fixed sanitized internal error.
Tool and prompt subscription baselines follow the same rendering policy as catalog list responses. With USE_DEFAULT_TEXT, provider or lookup failure can publish a canonical baseline and keep the subscription open. A later catalog invalidation compares the current text with that baseline, so recovery to translations or a return to canonical text can produce a list-change hint. With FAIL_REQUEST, an initial rendering failure rejects the listen request with the sanitized internal error and releases its reserved subscription capacity.
On framework-paged 2025 catalogs, failure to create the localization context uses the configured fallback locale. Recovery can restore a different negotiated locale. Either transition under USE_DEFAULT_TEXT can invalidate an in-progress cursor with -32602, even if client preferences did not change; restart enumeration from the first page without a cursor. A text lookup failure after context creation retains the negotiated locale and does not by itself cause this mismatch. See static catalog pages.
Rendering fallback does not replace the required localization context for an explicit McpCatalogAccessPolicy. If that context cannot be created, the request fails before policy evaluation; an unavailable context or a throwing policy is never treated as an allow decision.
Bidirectional text
Soklet validates translation results as Unicode but does not invent text direction or rewrite provider text. Valid bidirectional controls are preserved, including First Strong Isolate (FSI, U+2068) and Pop Directional Isolate (PDI, U+2069). Put the isolates required by your catalog around interpolated text whose direction is not known in advance, and test mixed-script identifiers, numbers, and punctuation under an RTL locale such as Arabic. Do not strip those controls in an adapter or assume that selecting an RTL locale makes unisolated dynamic content safe.
Key strategies
Pick exactly one per catalog and never fall back between them, because a silent dual lookup hides coverage gaps:
- Source text - the canonical default string is the key. Convenient when catalogs are authored from the source language, but same-wording fields cannot diverge and editing default text moves the key.
- Contextual coordinate -
McpTextCoordinate.toExternalKey()is the key. Editing default text never moves the key and same-wording fields stay independent, at the cost of explicit extraction when routing identity changes.
Caching and HTTP
The MCP cacheScope and ttlMs hints describe who may reuse a result and for how long, but they do not identify the selected locale. A client looking only at those fields cannot distinguish, for example, an English representation from a French representation of the same resource. When an McpLocalizer is configured, Soklet therefore emits every MCP cache hint as cacheScope: "private" with ttlMs: 0. Private scope keeps the representation out of a shared cache, and the zero TTL prevents it from being treated as fresh for a later request.
This is a final server-wide safety rule, not a default that application code can override. It is applied after the configured McpCachePolicy and after a handler's per-result TTL override from McpResourceOutput or McpResourcePage. Those policies and overrides work normally when no localizer is configured; on a localization-enabled server they cannot produce a positive emitted TTL or public scope.
This protocol-level rule does not change the separate HTTP policy: MCP transport responses still carry Cache-Control: no-store.
Framework catalog responses emit Content-Language with the canonical selected tag, or the configured fallback locale when a whole-response fallback occurred. That includes a successful subscriptions/listen opening on an endpoint with 2026-07-28 subscriptions enabled, including framework localization or task notifications without an application McpSubscriptionConfig publisher: Soklet pre-renders the terminal metadata before committing the SSE head, then applies the selected or whole-response-fallback language to the stream's intended audience. A later invalidateCatalogs() may discard stale pre-rendered terminal text, but it does not renegotiate that already-open stream's audience or response headers. Every non-preflight response from a localization-enabled endpoint also merges Accept-Language into Vary.
Multi-round requests
Framework-protected request state carries the selected language tag automatically. On a retry, Soklet verifies the state, passes the tag through getContinuationLocale(), and requires your context to report that exact locale - a different locale fails before your handler runs. One continuation observes exactly one language.
Application-protected state and cursors stay yours: a localized paginated handler puts the selected tag inside its own protected cursor, which reaches the provider through getResourceListCursor() before locale selection.
localized catalog and cursor example makes that order executable. The provider sees the exact opaque cursor before selection, may authenticate application claims and honor its canonical-locale pin even when Accept-Language conflicts, and the handler receives the identical cursor for full authentication plus catalog/localization revision comparison. Missing, tampered, expired, unauthorized, wrong-key, missing-snapshot, revision-drift, and unavailable-locale classifications all become the same application-owned no-data -32602; Soklet never signs, decodes, rewrites, or stores the cursor.
Reload
// 1. Load and validate the complete candidate catalog off the request path.
// 2. Atomically install the immutable snapshot your provider captures.
// 3. Then, on every applicable instance:
server.getLocalizationCatalogInvalidator().invalidateCatalogs();
// 1. Load and validate the complete candidate catalog off the request path.
// 2. Atomically install the immutable snapshot your provider captures.
// 3. Then, on every applicable instance:
server.getLocalizationCatalogInvalidator().invalidateCatalogs();
The control is local to one server; it distributes nothing. It publishes at most one coarse list-change notification per applicable family to eligible subscription streams, carrying no text, locale, revision, or key. A failed load never reaches step 3, so the previous snapshot survives.
Running a fleet
Modern and session-disabled 2025 localized requests can be routed round-robin. Each node creates a fresh request context from portable inputs and captures one immutable local catalog snapshot, so a response cannot combine text from two revisions. Soklet stores no distributed localization session and requires no sticky routing for those ordinary requests. Explicitly enabled 2025 sessions separately require node affinity learned from initialization; see session configuration.
A live SSE subscription is different: its connection is naturally pinned to the node that accepted it. Catalog invalidation is node-local too. After a successful catalog swap, call invalidateCatalogs() on every applicable node; Soklet does not broadcast that call for you. If a node disappears, the client opens a new subscription against a survivor. The survivor creates a new context from the repeated request preferences and authenticated application policy - it does not recover a locale, cursor, or context from the lost node.
Use one of these deployment patterns deliberately:
- Rolling activation: build and validate revision R42 on every node, then atomically install it and call
invalidateCatalogs()one node at a time. During the rollout, one request may receive R41 and a later request may receive R42, but each response remains internally consistent. - Fleet-atomic activation: stage and validate R42 everywhere, then use an application-owned coordinator or traffic switch to activate it as one deployment event. Soklet supplies no distributed catalog store or global activation transaction.
- Failed candidate: install nothing and notify no node. Every node keeps its prior snapshot, and subscribers receive no false invalidation.
Framework-protected multi-round state can carry its selected locale to another node, but every possible retry node must have compatible localization configuration and request-state protection. Built-in production rings must share the required key material; a custom protector owns its own fleet compatibility. Application-protected state and cursors remain application- owned and must be portable if they can cross nodes.
Test fleet behavior with your deployment coordinator: failed reload must keep the previous snapshot and emit no invalidation; rolling activation may produce different catalog revisions across responses while each response remains internally consistent. Invalidation reaches only the node's subscribers. Reconnect after node loss must use fresh authorization, and Soklet does not coordinate activation or replay lost events.
Application-owned content
For Skills, publish translated bundles as explicit locale alternatives and select one with the server's Skill variant selector. Authored documents, metadata, supporting files, and manifest hashes are immutable; the localizer does not translate them in place. Selecting a variant for discovery does not authorize its retrieval.
Tool, prompt, resource-read, custom resource-list, and Completion handlers receive the identical selected context through McpInvocationFeatures. Use getLocale() with your own localization library when producing application text. Soklet never post-processes handler results and never recursively rewrites arbitrary JSON - a title member inside your payload is data, not a translation slot.
For argument completion, applications own locale-sensitive matching and ranking. Natural-language suggestions may follow the selected locale; machine-valued arguments must retain their canonical accepted identifiers. Both prompt and template completers use the admitted request's context and authorization identity. Soklet does not translate suggestions or maintain a shared suggestion cache.
Privacy
Raw preferences, language tags, translated text, keys, coordinates, and revisions are never framework log fields, exception text, metric labels, or retained sets. A throwable from your provider is treated as untrusted localization data: Soklet catches it to choose the configured outcome and never forwards it to lifecycle callbacks, simulator completions, or logs.

