CodeGym /Courses /ChatGPT Apps /MCP Gateway and localization architecture: monolingual se...

MCP Gateway and localization architecture: monolingual servers, locale as a parameter, client state

ChatGPT Apps
Level 9 , Lesson 4
Available

1. Why think about localization architecture at all

As long as you have one language and a small catalog, it’s simple: you store gift_catalog.json, all texts are in Russian, and the MCP server dutifully serves these gifts to everyone. But as soon as you want:

  • an English UI for the US and Europe,
  • a separate Russian-language catalog with matryoshkas and books in Russian,
  • different markets (Amazon for the US, Ozon for Russia),

the naive approach “in every handler add another if (locale === "ru")” starts turning the code into a Christmas tree.

MCP is, on the one hand, a protocol and, on the other, a server implementation of that protocol. The server receives requests from ChatGPT with metadata, including locale and userLocation. The question is not whether it “can read the locale,” but where exactly in your architecture you account for that signal. You can do it in every tool, or you can move part of the logic into a separate layer — the Gateway.

A good localization architecture should answer three questions:

  1. Where do we decide which language and region to use?
  2. Where do we select the necessary data and integrations (catalogs, store APIs, currencies)?
  3. Where and how do we store user state (locale, currency, possibly some preferences) so we don’t have to pass it manually every time?

That’s exactly what we’ll cover today.

2. MCP, _meta, and the stateless nature: why locale must be passed explicitly

Before deciding where in the architecture to account for locale, it’s useful to recall what an MCP request looks like at the protocol level and what metadata the platform already sends.

An important fact: MCP requests are JSON-RPC messages. Each message is self-contained; the protocol doesn’t enforce a stateful session. Therefore, if you want the server to account for locale, you either need to:

  • pass it explicitly as a tool argument (locale in inputSchema), or
  • read it from _meta["openai/locale"], which ChatGPT adds to the request.

The simplest handler example that reads locale from _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;

    // Then use locale and country to pick a catalog
    const gifts = await loadGiftCatalog(locale, country);
    return { structuredContent: { gifts } };
  }
);

Here we don’t pass locale through arguments; we rely on _meta, which the SDK already put into extra. This is a perfectly valid option and will be useful in the first model — a single multilingual MCP.

In the second model — with a Gateway — _meta also plays a key role: the gateway reads locale from the metadata and uses it to decide where to forward the request. We’ll discuss separately below in what form to keep locale — only in _meta or also in the tools’ schemas.

3. Model 1: a single multilingual MCP server (“polyglot monolith”)

Let’s start with the simplest architectural option. You have one MCP server, one URL, one deployment, one codebase. Inside each tool you:

  1. Obtain the locale (from _meta or from an argument).
  2. Based on the locale, choose the required resources: gift_catalog.en.json, gift_catalog.ru.json, and so on.
  3. Return the result in the appropriate language.

Example for GiftGenius

Suppose we have two catalog files:

  • data/gift_catalog.en.json
  • data/gift_catalog.ru.json

Let’s write a small helper loadGiftCatalog(locale) that chooses the right file:

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; // array of gifts
}

Now our suggest_gifts tool can simply call this helper:

server.registerTool(
  "suggest_gifts",
  { title: "Gift selection", 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 } };
  }
);

This way, localization is encapsulated in one place — loadGiftCatalog — and tools simply pass locale in there. You can do the same for date formats, currencies, and any other region-specific concerns.

Pros and cons of this model

To keep it short, let’s summarize the pros and cons of this first model in a small table (only about “one MCP” for now — we’ll compare it to the Gateway later).

Criterion One multilingual MCP
Number of MCP instances 1
Where locale is applied In tool code
Deployment and scaling Simpler, single point
Catalog localization Via conditional file loading/requests
Amount of if (locale ...) Becomes a lot
Support for different markets/APIs The entire “zoo” in one codebase

This model is great for:

  • MVPs and small applications with 2–3 languages and not-too-different markets;
  • learning projects (for example, our GiftGenius in this course).

It fits worse when:

  • you end up with many languages,
  • teams and data differ significantly across markets (separate DBs, distinct e-commerce APIs, specific legal requirements).

And that’s exactly where the second model comes in.

4. Model 2: MCP Gateway + monolingual backend servers

Now imagine that GiftGenius operates in the US, Russia, and, say, Germany. For the US you call the Amazon API, for Russia — Ozon, for Germany — a local retailer. Each market has its own contract, specifics, and team. Stuffing everything into one MCP monolith is unpleasant.

The idea of model 2 is this:

Between ChatGPT and the actual MCP services sits a Gateway. To ChatGPT it looks like just another MCP server, but inside it routes requests to different backend servers, each of which “speaks” only one language and serves one market.

What it looks like on a diagram

First, let’s draw a comparison of the two models.

flowchart LR
    subgraph Model1["Model 1: Single MCP"]
      A1[ChatGPT] --> B1["GiftGenius MCP (multilingual)"]
    end

    subgraph Model2["Model 2: Gateway + monolinguals"]
      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, Local shop)"]
    end

From ChatGPT’s point of view in the second model there is only one MCP endpoint — the Gateway. Inside, it analyzes _meta["openai/locale"] and/or _meta["openai/userLocation"] and chooses the correct backend.

What the Gateway does (in the context of this lecture)

It’s important not to turn the Gateway into a “second monolith with all the business logic.” In our module its role is strictly limited:

  1. Accept the MCP message from ChatGPT (including _meta).
  2. Extract locale / userLocation.
  3. Based on that, select the appropriate backend server.
  4. Proxy the request there (JSON-RPC) and return the response back.

All decisions about which gift catalog to use and how exactly to call Amazon or Ozon remain inside the specific language MCP server. The Gateway doesn’t know what the “perfect gift for a mother-in-law” looks like. It only needs to know that for ru-RU it should call mcp-giftgenius-ru, and for en-USmcp-giftgenius-en.

A minimal MCP Gateway skeleton in TypeScript

We’ll simplify quite a bit to avoid drowning in details. Assume we have a helper callDownstreamTool that can talk to internal MCP servers over JSON-RPC (these could be HTTP requests or a persistent SSE connection, but we’ll leave those details to module 16).

import { Server } from "@modelcontextprotocol/sdk/server";

const server = new Server({ name: "giftgenius-gateway" });

function chooseBackend(locale?: string) {
  if (!locale) return "en";              // default
  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"
    // Call the same tool on the appropriate backend server
    return await callDownstreamTool(backendKey, "suggest_gifts", args, extra);
  }
);

The internal MCP servers register suggest_gifts with exactly the same contract, but each works only with its language/market and doesn’t know that other languages exist.

Similarly, the Gateway can proxy listTools, listResources, and other MCP methods — but that’s a topic for another module.

5. Comparing the two models for localization

We separately looked at the pros and cons of the “single MCP” model. Now let’s summarize the differences between both models across key parameters.

Criterion One multilingual MCP Gateway + monolingual MCP servers
Number of MCP services 1 1 Gateway + N backend servers
Where locale is applied Inside each tool (logic if locale ...) In the Gateway, which routes; inside services the language is fixed
UX flexibility (language switching) Easy, all in one place, the LLM just changes locale Possible, but you need to plan how the Gateway switches the backend
Infrastructure complexity Minimal Higher: separate deploys are needed for each language
Market isolation Low: one codebase, one process High: a RU server outage doesn’t break EN and vice versa
Support for separate teams Harder to split ownership Natural: RU, EN, DE teams can build their MCPs separately
Localization logic in code Mixed with business logic in each handler Concentrated in the Gateway and within the boundaries of a specific backend service

For our course we will mostly stick to model 1 (one MCP + locale as a parameter), and treat the Gateway model as a natural scaling path when you have a “real business” with dozens of markets. Nevertheless, since a Gateway is a natural next step, we’ll look at one important detail of this architecture: how to keep locale and the user’s country in session state.

6. Locale as part of client state in the Gateway

So far we’ve assumed each request contains everything needed. But in real life it’s convenient to keep some information in session state. For example:

  • the user once arrived with locale = "ru-RU" and userLocation.country = "RU";
  • afterwards you want to route all their requests to the RU backend, even if some intermediate calls come without an explicit locale in the arguments.

MCP has a useful field _meta["openai/subject"] — an anonymous user identifier that OpenAI sends to your services. You can use it as a session key.

A simple in-memory state implementation

Let’s write a tiny state layer in the Gateway (of course, in production, instead of a Map, it’s better to use Redis or another external store).

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);
}

Now, in the Gateway handler, we can first update the state and then use it to choose the backend server:

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);
  }
);

This way you “remember” the mapping clientIdlocale, country once and reuse it across subsequent tool calls without copying fields into every argument list.

Similarly, the Gateway can remember the preferred currency, price formats, or other settings that are useful for commerce logic (but that’s more for the ACP module).

7. GiftGenius: two scenarios and how the architecture choice affects them

To avoid the feeling that we’re just discussing abstract boxes, let’s look at concrete GiftGenius scenarios.

Scenario 1: User from Russia, writes in Russian

Assume we have:

  • _meta["openai/locale"] = "ru-RU",
  • _meta["openai/userLocation"].country = "RU".

The user writes: “Pick a gift for a colleague, likes board games, up to 3000 rubles.”

In model 1 (one MCP):

  1. The handler reads locale from _meta, gets "ru-RU".
  2. Loads gift_catalog.ru.json, where all names are in Russian and prices are in rubles.
  3. Filters by category and budget, returns a structured list of gifts in Russian.

In model 2 (Gateway + monolinguals):

  1. The Gateway reads locale and userLocation and decides this is a RU user.
  2. Routes the suggest_gifts call to mcp-giftgenius-ru.
  3. That server works only with the Russian catalog and the Ozon API and returns gifts in rubles.

In both cases the user sees everything in their native language, but in the second option your English MCP server doesn’t even know the Russian catalog exists.

Scenario 2: User from Germany, writes in English

Now:

  • _meta["openai/locale"] = "en",
  • _meta["openai/userLocation"].country = "DE".

The user writes: “Gift for my German coworker, budget 50 EUR”.

In model 1:

  • locale "en" yields English texts,
  • and country "DE" can be used to select a catalog with prices in euros and assortment adapted for Europe.

In model 2:

  • The Gateway can decide that locale = "en" → English service, but country = "DE" → products from a European warehouse; depending on your business logic, you can:
  • either route the request to mcp-giftgenius-en with a country=DE parameter,
  • or have a separate mcp-giftgenius-eu for Europe.

This makes it clear that locale (language) and region (userLocation) are different dimensions, and the Gateway is a convenient place to glue them together into a decision about “which service to call and which products to show.”

8. Locale in tool schemas vs. locale only in _meta

Regardless of whether you use a single MCP or a Gateway + monolingual services, there is a subtle but important point to discuss: should you store locale only in _meta or make it a tool argument?

There are two approaches.

First: rely only on _meta.

This is convenient because tool schemas aren’t cluttered with another field. The server reads locale from extra._meta and makes decisions on its own. In model 1 this is often sufficient.

Second: explicitly add locale (and possibly currency) to the tool’s inputSchema.

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"]
};

Then, in the system prompt, you can ask the model to always fill the locale argument using the user context value. This makes intentions explicit: the JSON arguments explicitly show which language the server should use. This approach is especially useful in more complex architectures where there is one common MCP that routes internally by locale to different services or resources.

In practice, both approaches are often combined: tool schemas include a locale field, but if for some reason the model doesn’t populate it, the server falls back to _meta["openai/locale"].

9. Where to draw the line between localization and “extra logic” in the Gateway

A trap that’s easy to fall into: since we have a smart Gateway, let’s have it:

  • decide which gifts to show,
  • format dates and prices itself,
  • assemble click reports, and so on.

That sounds tempting but turns the Gateway into a “second monolith” and complicates its updates and operations. In the industry practices of API gateways (and an MCP Gateway plays the same role), the focus is kept on a few tasks: authentication, authorization, routing, and light context enrichment. For example, a gateway can transform HTTP headers into convenient metadata. Business logic and heavy operations should live in backend services.

For localization, this means:

  • The Gateway can parse _meta["openai/locale"] and _meta["openai/userLocation"].
  • It can remember them in client state.
  • It can choose the appropriate language server or add locale/country to the request.

But the actual gift selection, filtering by age, budget, and so on — all of that should remain in MCP backends.

10. Typical mistakes when designing localization via MCP and a Gateway

Mistake #1: Relying only on “guessing” the language from user text.
Sometimes you may want to take the message text, run it through a language detector, and based on that decide which server to call. This can be a useful fallback but not the primary mechanism. The platform already gives you openai/locale and openai/userLocation, which reflect ChatGPT settings and the user’s environment. Ignoring these signals and playing “guess the language” is an effective way to break UX in the most unexpected cases.

Mistake #2: Keeping locale only “inside the model’s head” and not passing it to the server.
If locale doesn’t appear either in _meta or in tool arguments, the server knows nothing about the user’s language. The model may try to translate a Russian label like “knigi” into books, but that’s unreliable, especially if you have complex categories. The right path is to pass locale explicitly: either via a locale argument or by reading it from _meta and building your architecture around it.

Mistake #3: Moving all localization business logic into the Gateway.
If the Gateway starts choosing gifts, hitting databases, and fighting with external APIs, it stops being a lightweight router and becomes a heavy service that’s hard to scale and update. You’ll end up with two monoliths instead of one. Keep the Gateway as “dumb” as possible: it looks at locale/userLocation, chooses the right backend, and cleanly forwards metadata downstream.

Mistake #4: Hard-wiring routing only to IP or userLocation.
It’s tempting to do something simple: “if the country is RU — go to the RU server.” But a user can be in Germany and still want the interface in Russian, or they might ask to “switch to English” mid-session. If your Gateway doesn’t consider openai/locale and the user’s desire to change language, routing becomes “concrete” and breaks UX. It’s better to rely on a combination of locale and userLocation and keep the ability to override settings via session state.

Mistake #5: Not using _meta["openai/subject"] and duplicating all parameters in every argument list.
When you start dragging locale, country, currency, userId, and half the interface into every tool argument, life gets gloomy fast. MCP already passes an anonymous user identifier via _meta["openai/subject"], and you can keep all this information in client state on the Gateway or backend side. This simplifies contracts and reduces the risk of argument desynchronization.

Mistake #6: No evolution strategy: “let’s build a complex Gateway for ten languages right away.”
It’s often tempting to do it perfectly from the start: a Gateway, five languages, three regions, ten MCP services. In practice, it’s easier to start with the model “one MCP + locale parameter or _meta,” get the behavior stable, and then extract the Gateway and monolingual services as you grow. Trying to build a huge “zoo” right away almost guarantees a delayed release and harder debugging.

1
Task
ChatGPT Apps, level 9, lesson 4
Locked
Localization of tool descriptions and JSON Schema (bilingual descriptions)
Localization of tool descriptions and JSON Schema (bilingual descriptions)
1
Survey/quiz
Localization, level 9, lesson 4
Unavailable
Localization
Localization (UI, data, tool descriptions)
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION