1. Po co w ogóle myśleć o architekturze lokalizacji
Dopóki masz jeden język i mały katalog, wszystko jest proste: trzymasz gift_catalog.json, wszystkie teksty są po rosyjsku, a serwer MCP uczciwie wysyła te prezenty wszystkim. Ale gdy tylko chcesz:
- angielski interfejs dla USA i Europy,
- oddzielny rosyjskojęzyczny katalog z matrioszkami i książkami po rosyjsku,
- różne rynki (Amazon dla USA, Ozon dla Rosji),
naiwne podejście „w każdym handlerze jeszcze jedno if (locale === "ru")” zaczyna zamieniać kod w świąteczną choinkę.
MCP to z jednej strony protokół, a z drugiej — serwerowa implementacja tego protokołu. Serwer otrzymuje żądania od ChatGPT z metadanymi, w tym locale i userLocation. Pytanie nie brzmi „czy potrafi odczytać locale”, tylko gdzie dokładnie w architekturze uwzględniasz ten sygnał. Można w każdym narzędziu, a można przenieść część logiki do oddzielnej warstwy — Gateway.
Dobra architektura lokalizacji powinna odpowiadać na trzy pytania:
- Gdzie podejmujemy decyzję, jakiego języka i regionu użyć.
- Gdzie wybieramy odpowiednie dane i integracje (katalogi, API sklepów, waluty).
- Gdzie i jak przechowujemy stan użytkownika (locale, waluta, ewentualnie jego preferencje), aby nie przekazywać tego ręcznie za każdym razem.
Dzisiaj właśnie to rozłożymy na czynniki pierwsze.
2. MCP, _meta i stateless: dlaczego locale trzeba przekazywać jawnie
Zanim zdecydujesz, gdzie w architekturze uwzględniać locale, warto pamiętać, jak wygląda żądanie MCP na poziomie protokołu i jakie metadane platforma już przekazuje.
Ważny fakt: żądania MCP to komunikaty JSON‑RPC. Każdy komunikat jest niezależny, protokół nie narzuca stateful‑sesji. Dlatego jeśli chcesz, by serwer uwzględniał lokalizację, trzeba ją albo:
- przekazać jawnie jako argument narzędzia (locale w inputSchema), albo
- odczytać z _meta["openai/locale"], które ChatGPT dodaje do żądania.
Najprostszy przykład handlera, który czyta locale z _meta:
server.registerTool(
"suggest_gifts",
{
title: "Suggest gifts",
inputSchema: { /* ... */ },
},
async (args, extra) => {
const meta = extra?._meta ?? {};
const locale = (meta["openai/locale"] as string | undefined) || "en-US";
const country = meta["openai/userLocation"]?.country as string | undefined;
// Dalej używamy locale i country do wyboru katalogu
const gifts = await loadGiftCatalog(locale, country);
return { structuredContent: { gifts } };
}
);
Tutaj nie przekazujemy locale przez argumenty, tylko polegamy na _meta, które SDK już włożył do extra. To działa, i przyda nam się w pierwszym modelu — z jednym wielojęzycznym MCP.
W drugim modelu — z Gateway — _meta też gra kluczową rolę: bramka odczytuje locale z metadanych i na tej podstawie decyduje, dokąd dalej wysłać żądanie. W jakiej formie trzymać locale — tylko w _meta czy również w schematach narzędzi — omówimy osobno poniżej.
3. Model 1: jeden wielojęzyczny serwer MCP („poliglot‑monolit”)
Zacznijmy od najprostszej opcji architektonicznej. Masz jeden serwer MCP, jeden URL, jeden deploy, jedną bazę kodu. Wewnątrz każdego narzędzia:
- Pobierasz locale (z _meta lub z argumentu).
- Na podstawie locale wybierasz zasoby: gift_catalog.en.json, gift_catalog.ru.json itd.
- Zwracasz wynik już w odpowiednim języku.
Przykład dla GiftGenius
Załóżmy, że mamy dwa pliki z katalogami:
- data/gift_catalog.en.json
- data/gift_catalog.ru.json
Zróbmy mały helper loadGiftCatalog(locale), który wybiera właściwy plik:
async function loadGiftCatalog(locale: string) {
const lang = locale.split("-")[0]; // "en-US" → "en"
const fileName = lang === "ru" ? "gift_catalog.ru.json" : "gift_catalog.en.json";
const data = await import(`../data/${fileName}`);
return data.default; // tablica prezentów
}
Teraz nasze narzędzie suggest_gifts może po prostu wołać ten helper:
server.registerTool(
"suggest_gifts",
{ title: "Dobór prezentów", inputSchema: {/* ... */} },
async (args, extra) => {
const locale = (extra?._meta?.["openai/locale"] as string) || "en-US";
const catalog = await loadGiftCatalog(locale);
const filtered = filterGifts(catalog, args);
return { structuredContent: { gifts: filtered } };
}
);
Wychodzi na to, że lokalizacja jest ukryta w jednym miejscu — w loadGiftCatalog, a narzędzia po prostu przekazują tam locale. Tak samo można dobrać formaty dat, waluty i inne rzeczy zależne od regionu.
Zalety i wady tego modelu
Aby nie utonąć w tekście, zbierzmy plusy i minusy pierwszego modelu w krótkiej tabeli (na razie tylko o „jednym MCP” — do porównania z Gateway wrócimy później).
| Kryterium | Jeden wielojęzyczny MCP |
|---|---|
| Liczba instancji MCP | 1 |
| Gdzie uwzględniane jest locale | W kodzie narzędzi |
| Deploy i skalowanie | Prościej, jeden punkt |
| Lokalizacja katalogów | Przez warunkowe ładowanie plików/zapytań |
| Kodu if (locale ...) | Robi się dużo |
| Wsparcie różnych rynków/API | Całe „zoo” w jednym kodzie |
Ten model świetnie pasuje do:
- MVP i małych aplikacji, gdzie są 2–3 języki i niezbyt różne rynki;
- projektów edukacyjnych (np. nasz GiftGenius w ramach kursu).
Gorzej się sprawdza, gdy:
- języków robi się dużo,
- zespoły i dane dla różnych rynków są zasadniczo różne (oddzielne bazy danych, własne e‑commerce API, wymagania prawne).
I właśnie w takich przypadkach wchodzi na scenę drugi model.
4. Model 2: MCP Gateway + jednojęzyczne serwery backendowe
Wyobraźmy sobie teraz, że GiftGenius działa w USA, w Rosji i, powiedzmy, w Niemczech. Dla USA korzystasz z Amazon API, dla Rosji — z Ozon, dla Niemiec — z lokalnego detalisty. Każdy rynek ma własny kontrakt, specyfikę i zespół. Wpychanie wszystkiego do jednego monolitu MCP jest nieprzyjemne.
Idea modelu 2 jest taka:
Między ChatGPT a właściwymi usługami MCP stoi Gateway. Dla ChatGPT to po prostu kolejny serwer MCP, a w środku on trasuje żądania do różnych serwerów backendowych, z których każdy „mówi” tylko w jednym języku i obsługuje jeden rynek.
Jak to wygląda na diagramie
Najpierw narysujmy porównanie dwóch modeli.
flowchart LR
subgraph Model1["Model 1: Jeden MCP"]
A1[ChatGPT] --> B1["GiftGenius MCP (wielojęzyczny)"]
end
subgraph Model2["Model 2: Gateway + jednojęzyczne"]
A2[ChatGPT] --> G[MCP Gateway]
G --> R["GiftGenius MCP RU (ru-RU, Ozon)"]
G --> E["GiftGenius MCP EN (en-US, Amazon"]
G --> D["GiftGenius MCP DE (de-DE, lokalny sklep)"]
end
Z punktu widzenia ChatGPT w drugim modelu istnieje tylko jeden endpoint MCP — Gateway. W środku analizuje on _meta["openai/locale"] i/lub _meta["openai/userLocation"] i wybiera właściwy backend.
Co robi Gateway (w kontekście tej lekcji)
Ważne, aby nie zamienić Gateway w „drugi monolit z całą logiką biznesową”. W naszym module jego rola jest mocno ograniczona:
- Przyjąć komunikat MCP od ChatGPT (wraz z _meta).
- Wyciągnąć locale / userLocation.
- Na tej podstawie wybrać właściwy serwer backendowy.
- Przeproxywać tam żądanie (JSON‑RPC) i zwrócić odpowiedź.
Wszystkie decyzje, jaki dokładnie katalog prezentów brać, jak wołać Amazon lub Ozon, zostają wewnątrz konkretnego językowego serwera MCP. Gateway nie wie, jak wygląda „idealny prezent dla teściowej”. Wystarczy mu wiedzieć, że dla ru-RU trzeba iść do mcp-giftgenius-ru, a dla en-US — do mcp-giftgenius-en.
Najprostszy szkielet MCP Gateway w TypeScript
Mocno uprośćmy, żeby nie utonąć w detalach. Załóżmy, że mamy helper callDownstreamTool, który potrafi rozmawiać z wewnętrznymi serwerami MCP przez JSON‑RPC (to mogłyby być żądania HTTP albo stałe połączenie SSE, ale szczegóły zostawimy modułowi 16).
import { Server } from "@modelcontextprotocol/sdk/server";
const server = new Server({ name: "giftgenius-gateway" });
function chooseBackend(locale?: string) {
if (!locale) return "en"; // domyślne
const lang = locale.split("-")[0]; // ru-RU → ru
return ["ru", "de"].includes(lang) ? lang : "en";
}
server.registerTool(
"suggest_gifts",
{ title: "Suggest gifts (via gateway)", inputSchema: {/* ... */} },
async (args, extra) => {
const locale = extra?._meta?.["openai/locale"] as string | undefined;
const backendKey = chooseBackend(locale); // "ru" | "en" | "de"
// Wywołujemy to samo narzędzie na odpowiednim serwerze backendowym
return await callDownstreamTool(backendKey, "suggest_gifts", args, extra);
}
);
Wewnętrzne serwery MCP rejestrują u siebie suggest_gifts z dokładnie takim samym kontraktem, ale każdy działa tylko w swoim języku/rynku i nie wie, że gdzieś istnieją inne języki.
Tak samo Gateway może proxy’ować listTools, listResources i inne metody MCP, ale to już temat osobnego modułu.
5. Porównanie dwóch modeli lokalizacji
Wcześniej osobno spojrzeliśmy na plusy i minusy modelu „jeden MCP”. Teraz zbierzmy różnice obu modeli według kluczowych parametrów.
| Kryterium | Jeden wielojęzyczny MCP | Gateway + jednojęzyczne serwery MCP |
|---|---|---|
| Liczba usług MCP | 1 | 1 Gateway + N serwerów backendowych |
| Gdzie uwzględniane jest locale | W każdym narzędziu (logika if locale ...) | W Gateway, który trasuje; wewnątrz serwisów język jest stały |
| Elastyczność UX (zmiana języka) | Łatwo, wszystko w jednym miejscu, LLM po prostu zmienia locale | Możliwe, ale trzeba przemyśleć, jak Gateway przełączy backend |
| Złożoność infrastruktury | Minimalna | Wyższa: potrzebne oddzielne deploye dla każdego języka |
| Izolacja między rynkami | Niska: jeden kod, jeden proces | Wysoka: awaria serwera RU nie psuje EN i odwrotnie |
| Wsparcie różnych zespołów | Trudniej podzielić odpowiedzialność | Naturalnie: zespoły RU, EN, DE mogą rozwijać swoje MCP osobno |
| Logika lokalizacji w kodzie | Wymieszana z logiką biznesową w każdym handlerze | Skoncentrowana w Gateway i na granicach konkretnego serwisu backendowego |
W naszym kursie edukacyjnym będziemy głównie trzymać się modelu 1 (jeden MCP + locale jako parametr), a model z Gateway potraktujemy jako naturalną ścieżkę skalowania, gdy masz już „prawdziwy biznes” z dziesiątkami rynków. Mimo to, ponieważ Gateway jest naturalnym kolejnym krokiem, przyjrzymy się ważnemu detalowi tej architektury: jak przechowywać locale i kraj użytkownika w stanie sesji.
6. Locale jako część stanu klienta w Gateway
Do tej pory zakładaliśmy, że każde żądanie zawiera wszystko, co potrzebne. W praktyce wygodnie jest część informacji trzymać w stanie sesji. Na przykład:
- użytkownik raz przyszedł z locale = "ru-RU" i userLocation.country = "RU";
- dalej chcesz trasować wszystkie jego żądania do backendu RU, nawet jeśli jakieś pośrednie wywołania przychodzą bez jawnego locale w argumentach.
MCP ma przydatne pole _meta["openai/subject"] — anonimowy identyfikator użytkownika, który OpenAI wysyła do twoich usług. Można go użyć jako klucza sesji.
Prosta implementacja stanu w pamięci
Napiszmy malutką warstwę stanu w Gateway (oczywiście w produkcji zamiast Map lepiej użyć Redis lub innego zewnętrznego magazynu).
type ClientState = {
locale?: string;
country?: string;
};
const clientState = new Map<string, ClientState>();
function getClientId(extra: any): string | undefined {
return extra?._meta?.["openai/subject"] as string | undefined;
}
function updateClientState(extra: any) {
const clientId = getClientId(extra);
if (!clientId) return;
const meta = extra?._meta ?? {};
const current = clientState.get(clientId) ?? {};
const next: ClientState = {
locale: meta["openai/locale"] || current.locale,
country: meta["openai/userLocation"]?.country || current.country,
};
clientState.set(clientId, next);
}
Teraz w handlerze Gateway możemy najpierw zaktualizować stan, a potem użyć go przy wyborze serwera backendowego:
server.registerTool(
"suggest_gifts",
{ title: "Suggest gifts (via gateway)", inputSchema: {/* ... */} },
async (args, extra) => {
updateClientState(extra);
const clientId = getClientId(extra)!;
const state = clientState.get(clientId);
const locale = state?.locale || "en-US";
const backendKey = chooseBackend(locale);
return await callDownstreamTool(backendKey, "suggest_gifts", args, extra);
}
);
W ten sposób raz „zapamiętujesz” mapowanie clientId → locale, country i możesz go używać we wszystkich kolejnych wywołaniach narzędzi, bez kopiowania pól w każdym argumencie.
Tak samo Gateway może zapamiętać preferowaną walutę, format cen lub inne ustawienia przydatne w logice commerce (ale o tym więcej w module o ACP).
7. GiftGenius: dwa scenariusze a wpływ wyboru architektury
Aby nie było wrażenia, że omawiamy tylko abstrakcyjne kwadraty, spójrzmy na konkretne scenariusze GiftGenius.
Scenariusz 1: Użytkownik z Rosji, pisze po rosyjsku
Załóżmy:
- _meta["openai/locale"] = "ru-RU",
- _meta["openai/userLocation"].country = "RU".
Użytkownik pisze: „Dobierz prezent dla kolegi, lubi gry planszowe, do 3000 rubli”.
W modelu 1 (jeden MCP):
- Handler czyta locale z _meta, dostaje "ru-RU".
- Ładuje gift_catalog.ru.json, gdzie wszystkie nazwy są po rosyjsku, ceny w rublach.
- Filtruje po kategorii i budżecie, zwraca ustrukturyzowaną listę prezentów po rosyjsku.
W modelu 2 (Gateway + jednojęzyczne serwery):
- Gateway czyta locale i userLocation, stwierdza, że to użytkownik RU.
- Kieruje wywołanie suggest_gifts do mcp-giftgenius-ru.
- Ten działa wyłącznie z rosyjskim katalogiem i Ozon API, zwraca prezenty w rublach.
W obu przypadkach użytkownik wszystko widzi w swoim języku, ale w drugim wariancie twój angielski serwer MCP nawet nie wie o istnieniu katalogu dla Rosji.
Scenariusz 2: Użytkownik z Niemiec, pisze po angielsku
Teraz:
- _meta["openai/locale"] = "en",
- _meta["openai/userLocation"].country = "DE".
Użytkownik pisze: „Gift for my German coworker, budget 50 EUR”.
W modelu 1:
- locale "en" daje angielskie teksty,
- a country "DE" możesz użyć do wyboru katalogu, gdzie ceny są w euro, a asortyment dopasowany do Europy.
W modelu 2:
- Gateway może zdecydować, że locale = "en" → angielski serwis, ale country = "DE" → produkty z europejskiego magazynu; w zależności od logiki biznesowej możesz:
- albo skierować żądanie do mcp-giftgenius-en z parametrem country=DE,
- albo mieć oddzielny mcp-giftgenius-eu dla Europy.
Tutaj dobrze widać, że lokalizacja (język) i region (userLocation) to różne wymiary, a Gateway to wygodne miejsce, by skleić je w decyzję „jaki serwis wywołać i jakie produkty pokazać”.
8. Locale w schematach narzędzi vs locale tylko w _meta
Niezależnie od tego, czy używasz jednego MCP, czy układu Gateway + jednojęzyczne serwisy, na koniec warto omówić subtelny, ale ważny punkt: trzymać locale tylko w _meta, czy też dodać je jako argument narzędzia?
Są dwa podejścia.
Pierwsze: polegać wyłącznie na _meta.
To wygodne, bo schematy narzędzi nie zaśmiecają się kolejnym polem. Serwer odczytuje locale z extra._meta i sam podejmuje decyzje. W modelu 1 to często wystarcza.
Drugie: jawnie dodać locale (i ewentualnie currency) do inputSchema narzędzia.
const suggestGiftsSchema = {
type: "object",
properties: {
locale: {
type: "string",
description: "User locale in BCP 47 format, e.g. en-US or ru-RU"
},
recipient: { type: "string" },
// ...
},
required: ["recipient"]
};
Następnie w system‑prome możesz poprosić model, by zawsze wypełniał locale argumentem, używając wartości z kontekstu użytkownika. To czyni intencje przejrzystymi: w JSON‑argumentach wprost widać, w jakim języku serwer ma działać. Takie podejście jest szczególnie przydatne w bardziej złożonej architekturze, gdzie jest jeden wspólny MCP, który po locale wewnątrz trasuje do różnych serwisów lub zasobów.
W praktyce często łączy się oba podejścia: w schematach jest pole locale, ale jeśli z jakiegoś powodu model go nie wypełnił, serwer asekuracyjnie korzysta z _meta["openai/locale"].
9. Gdzie przebiega granica między lokalizacją a „zbędną logiką” w Gateway
Pułapka, w którą łatwo wpaść: skoro mamy sprytny Gateway, to może on będzie:
- sam decydował, jakie prezenty pokazać,
- sam formatował daty i ceny,
- sam zbierał raporty z klików i tak dalej.
Brzmi kusząco, ale zamienia Gateway w „drugi monolit” i utrudnia jego aktualizację i eksploatację. W praktykach branżowych API‑gateway (a MCP Gateway z definicji pełni podobną rolę) ich fokus trzyma się kilku zadań: uwierzytelnianie, autoryzacja, trasowanie i lekkie wzbogacanie kontekstu. Na przykład bramka może zamieniać nagłówki HTTP na wygodne metadane. Logika biznesowa i ciężkie operacje powinny żyć w serwisach backendowych.
Dla lokalizacji oznacza to:
- Gateway może sparsować _meta["openai/locale"] i _meta["openai/userLocation"].
- Może je zapamiętać w stanie klienta.
- Może wybrać właściwy serwer językowy lub dodać do żądania pole locale/country.
Ale sam dobór prezentów, filtrowanie po wieku, budżecie itp. — to wszystko powinno pozostać w backendach MCP.
10. Typowe błędy przy projektowaniu lokalizacji z MCP i Gateway
Błąd nr 1: Poleganie wyłącznie na „zgadywaniu” języka po tekście użytkownika.
Czasem kusi, by wziąć treść wiadomości, przepuścić przez language‑detector i na tej podstawie decydować, który serwer wywołać. To może być użyteczny fallback, ale nie mechanizm podstawowy. Platforma już daje openai/locale i openai/userLocation, które uwzględniają ustawienia ChatGPT i otoczenie użytkownika. Ignorowanie tych sygnałów i zabawa w „zgadnij język” to prosty sposób na psucie UX w najbardziej zaskakujących przypadkach.
Błąd nr 2: Trzymanie locale tylko „w głowie” modelu i nieprzekazywanie go na serwer.
Jeśli locale nie pojawia się ani w _meta, ani w argumentach narzędzia, serwer nic nie wie o języku użytkownika. Model może spróbować przetłumaczyć ciąg „книги” na books, ale to zawodne, zwłaszcza przy złożonych kategoriach. Właściwa droga — jawnie przekazywać locale: albo przez argument locale, albo czytać z _meta i budować wokół tego architekturę.
Błąd nr 3: Przenoszenie całej logiki biznesowej lokalizacji do Gateway.
Jeśli Gateway zaczyna sam dobierać prezenty, chodzić do baz danych i walczyć z zewnętrznymi API, przestaje być lekkim routerem i staje się ciężkim serwisem, który trudno skalować i aktualizować. W efekcie dostajesz dwa monolity zamiast jednego. Lepiej trzymać Gateway możliwie „głupim”: patrzy na locale/userLocation, wybiera właściwy backend i starannie przekazuje metadane dalej.
Błąd nr 4: Sztywny routing tylko po IP lub userLocation.
Kusi prostota: „jeśli kraj to RU — idziemy na serwer RU”. Ale użytkownik może być w Niemczech i nadal chcieć interfejs po rosyjsku, albo może poprosić „switch to English” w środku sesji. Jeśli w Gateway nie uwzględniasz openai/locale i możliwej chęci zmiany języka, routing staje się „betonowy” i psuje UX. Lepiej opierać się na kombinacji locale i userLocation, a także mieć możliwość nadpisania ustawień przez stan sesji.
Błąd nr 5: Nie używanie _meta["openai/subject"] i dublowanie wszystkich parametrów w każdym argumencie.
Gdy w każdym argumencie narzędzia ciągniesz locale, country, currency, userId i pół interfejsu, życie szybko staje się smutne. MCP już przekazuje anonimowy identyfikator użytkownika przez _meta["openai/subject"], i możesz trzymać te informacje w stanie klienta po stronie Gateway lub backendu. To uprości kontrakty i zmniejszy ryzyko rozjazdu argumentów.
Błąd nr 6: Brak strategii ewolucji: „od razu budujemy skomplikowany Gateway na dziesięć języków”.
Często chce się od razu zrobić idealnie: Gateway, pięć języków, trzy regiony, dziesięć serwisów MCP. W praktyce łatwiej zacząć od modelu „jeden MCP + parametr locale lub _meta”, doprowadzić zachowanie do stabilności, a potem wyodrębniać Gateway i jednojęzyczne serwisy wraz ze wzrostem. Próba zbudowania od razu ogromnego „zoo” niemal gwarantuje opóźnienie wydania i utrudni debugowanie.
GO TO FULL VERSION