CodeGym /课程 /ChatGPT Apps /MCP Gateway 与本地化架构:单语服务器、将 locale 作为参数、客户端状态

MCP Gateway 与本地化架构:单语服务器、将 locale 作为参数、客户端状态

ChatGPT Apps
第 9 级 , 课程 4
可用

1. 为什么要考虑本地化架构

当你只有一种语言且目录很小,一切都很简单:你维护一个 gift_catalog.json,所有文本都是俄语,MCP‑服务器会如实把这些礼物返回给所有用户。但一旦你希望:

  • 面向美国和欧洲的英文 UI,
  • 单独的俄语目录(包含套娃和俄语书籍),
  • 不同市场(美国用 Amazon,俄罗斯用 Ozon),

天真的做法是在每个 handler 里再加一个 iflocale === "ru"),最终会把代码写成“圣诞树式分支”。

MCP 一方面是协议,另一方面是该协议的服务器实现。服务器会接收来自 ChatGPT 的请求及其元数据,其中包括 localeuserLocation。问题不在于“能不能读到 locale”,而在于你在架构的哪个位置利用这个信号。可以在每个工具里处理,也可以把一部分逻辑抽到单独一层——Gateway。

一个好的本地化架构需要回答三个问题:

  1. 我们在哪里决定使用哪种语言与地区。
  2. 我们在哪里选择所需的数据与集成(目录、商店 API、货币)。
  3. 我们在哪里以及如何保存用户状态(locale、货币,乃至一些偏好),以避免每次都手动传递。

今天我们就来拆解这些。

2. MCP、_meta 与无状态特性:为什么必须显式传递 locale

在决定架构中哪里处理 locale 之前,先回顾一下协议层面的 MCP 请求长什么样,以及平台已经帮你传了哪些元数据。

要点提醒:MCP 请求是 JSON‑RPC 消息。每条消息都是独立的,协议并不强制有状态会话。因此,如果你希望服务器考虑本地化,就需要要么:

  • 把它作为工具参数显式传入(localeinputSchema 中),要么
  • _meta["openai/locale"] 读取,ChatGPT 会把它加到请求里。

这是一个从 _meta 读取 locale 的最简 handler 示例:

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;

    // 接下来用 locale 和 country 选择目录
    const gifts = await loadGiftCatalog(locale, country);
    return { structuredContent: { gifts } };
  }
);

这里我们不通过参数传递 locale,而是依赖 SDK 已经放在 extra 里的 _meta。这完全可行,并且会在第一种模型(单个多语言 MCP)中派上用场。

在第二种模型(带 Gateway)中,_meta 同样关键:网关从元数据里读取 locale,并据此决定把请求转发到哪里。 至于 locale 具体以什么形式存在——只放在 _meta 里,还是也体现在工具的 schema 中——我们会在下面单独讨论。

3. 模型 1:单个多语言 MCP‑服务器(“多语单体”)

先从最简单的架构开始。你有一个 MCP 服务器、一个 URL、一次部署、一套代码。在每个工具内部你:

  1. 获取 locale(来自 _meta 或参数)。
  2. 基于 locale 选择资源:gift_catalog.en.jsongift_catalog.ru.json 等。
  3. 用正确的语言返回结果。

GiftGenius 示例

假设我们有两个目录文件:

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

写一个小工具函数 loadGiftCatalog(locale) 来选择需要的文件:

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; // 礼物数组
}

现在我们的工具 suggest_gifts 只需调用这个 helper:

server.registerTool(
  "suggest_gifts",
  { title: "礼物推荐", 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 } };
  }
);

这样,本地化被封装在一个地方——loadGiftCatalog,工具只需把 locale 传进去。 日期格式、货币以及其他地域相关的内容也可以同理处理。

该模型的优缺点

为了精炼内容,我们把该模型(“单个 MCP”)的优缺点先放在一个小表里(与 Gateway 的对比稍后再看)。

指标 单个多语言 MCP
MCP 实例数量 1
在哪一层考虑 locale 在工具代码中
部署与扩缩 更简单,单点
目录本地化 按需加载文件/请求
if (locale ...) 分支数量 会变多
支持不同市场/API 所有杂糅都在同一份代码中

该模型适用于:

  • MVP 和小型应用,只有 2–3 种语言且市场差异不大;
  • 教学项目(例如本课程中的 GiftGenius)。

不太适合当:

  • 语言越来越多,
  • 不同市场在团队和数据上差异很大(独立数据库、各自的电商 API、合规要求不同)。

在这些情况下,就该看第二种模型了。

4. 模型 2:MCP Gateway + 单语后端服务器

现在设想 GiftGenius 同时在美国、俄罗斯和德国运营。美国走 Amazon API,俄罗斯走 Ozon,德国走本地零售商。每个市场的契约、细节与团队都不同。把一切都塞进一个 MCP 单体并不理想。

模型 2 的想法是:

在 ChatGPT 与真实的 MCP 服务之间放一个Gateway。对 ChatGPT 来说它就是另一个 MCP 服务器,但内部会把请求路由到不同的后端服务器。每个后端“只说”一种语言,并面向一个市场。

图示

先画一张两种模型的对比图。

flowchart LR
    subgraph Model1["模型 1:单个 MCP"]
      A1[ChatGPT] --> B1["GiftGenius MCP (多语言)"]
    end

    subgraph Model2["模型 2:Gateway + 单语服务"]
      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

在 ChatGPT 看来,第二种模型下只有一个 MCP endpoint——Gateway。内部它会分析 _meta["openai/locale"] 和/或 _meta["openai/userLocation"],并选择正确的后端。

Gateway 的职责(本讲范围内)

关键是别把 Gateway 变成“装满业务逻辑的第二个单体”。在本模块里它的职责非常克制:

  1. 接收来自 ChatGPT 的 MCP 消息(包含 _meta)。
  2. 提取 locale / userLocation。
  3. 据此选择目标后端服务器。
  4. 把请求(JSON‑RPC)代理过去,并把响应转回。

至于选哪本礼物目录、如何调用 Amazon 或 Ozon,都留在具体的语言 MCP 服务器里。Gateway 不需要知道“给某位长辈的完美礼物”。它只需要知道:ru-RUmcp-giftgenius-ruen-USmcp-giftgenius-en

TypeScript 版 MCP Gateway 最小骨架

我们大幅简化以免陷入细节。假设有个 helper callDownstreamTool,能通过 JSON‑RPC 与内部 MCP 服务器通信(可能是 HTTP 请求或持久的 SSE 连接,细节留到模块 16)。

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

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

function chooseBackend(locale?: string) {
  if (!locale) return "en";              // 默认
  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"
    // 在相应的后端服务器上调用同名工具
    return await callDownstreamTool(backendKey, "suggest_gifts", args, extra);
  }
);

内部的 MCP 服务器会注册相同契约的 suggest_gifts,但每个只处理自身的语言/市场,并且不关心其他语言的存在。

Gateway 也可以同样代理 listToolslistResources 等 MCP 方法,但这是另一个模块的话题。

5. 两种本地化模型对比

前面我们单独看了“单个 MCP”模型的优缺点。现在从几个关键维度把两种模型并列比较。

指标 单个多语言 MCP Gateway + 单语 MCP 服务器
MCP 服务数量 1 1 个 Gateway + N 个后端服务器
在哪一层考虑 locale 在每个工具内部(if locale ... 逻辑) 在 Gateway 中做路由;服务内部语言固定
UX 灵活性(切换语言) 容易,集中在一处,LLM 只需改 locale 可以,但要设计 Gateway 如何切换后端
基础设施复杂度 最低 更高:每种语言需要独立部署
按市场隔离性 低:同一份代码、同一进程 高:RU 服务挂了不影响 EN,反之亦然
多团队协作支持 难以清晰划分责任 自然:RU、EN、DE 团队可各自迭代各自的 MCP
本地化逻辑在代码中的位置 与业务逻辑混在每个 handler 里 集中在 Gateway 和各自后端服务的边界

在我们的课程中主要采用模型 1(单个 MCP + locale 参数),而把 Gateway 模型视为你需要扩展到“真实业务、面向多个市场”时的自然演进路径。 既然 Gateway 是自然的下一步,我们就看一个关键细节:如何把用户的 locale 与国家存进会话状态。

6. 在 Gateway 中将 locale 作为客户端状态的一部分

到目前为止我们假设每次请求都包含了所需的一切。但在现实中,把部分信息存到会话状态更方便。例如:

  • 用户第一次带着 locale = "ru-RU"userLocation.country = "RU"
  • 之后即使某些请求没显式传 locale,你也希望把他的调用都路由到 RU 后端。

MCP 有个很有用的字段 _meta["openai/subject"]——OpenAI 发送到你服务的匿名用户标识。它可以作为会话键。

内存版的简易状态实现

我们在 Gateway 里写一个很小的 state 层(当然,生产环境里应该用 Redis 或其他外部存储来替代 Map)。

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

现在在 Gateway 的 handler 里,可以先更新状态,再用它来选择后端服务器:

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

这样,你只需一次把 clientIdlocalecountry 这组信息“记住”,随后在所有工具调用中都能复用,无需在每个参数里到处复制。

同理,Gateway 还能记住偏好的货币、价格格式或其他对电商逻辑有用的设置(更多会在 ACP 模块中讨论)。

7. GiftGenius:两个场景与架构选择的影响

为了不陷入抽象的“方框图”,我们来看 GiftGenius 的两个具体场景。

场景 1:来自俄罗斯的用户,用俄语交流

假设:

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

用户发来消息:“给同事挑个礼物,他喜欢桌游,预算 3000 卢布以内”。

在模型 1(单个 MCP)中:

  1. handler 从 _meta 读取 locale,得到 "ru-RU"
  2. 加载 gift_catalog.ru.json,其中名称是俄语、价格为卢布;
  3. 按类别与预算过滤,返回俄语的结构化礼物列表。

在模型 2(Gateway + 单语服务)中:

  1. Gateway 读取 locale 和 userLocation,判断该用户属于 RU;
  2. suggest_gifts 调用路由到 mcp-giftgenius-ru
  3. 该服务只处理俄语目录与 Ozon API,并以卢布返回结果。

两种情况下用户都能看到母语内容,但在第二种方案中,你的英文 MCP 服务器甚至不知道俄罗斯目录的存在。

场景 2:来自德国的用户,用英语交流

现在:

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

用户发来消息:“Gift for my German coworker, budget 50 EUR”。

在模型 1 中:

  • locale "en" 决定文本用英文,
  • 而 country "DE" 可用于选择欧洲目录(价格以欧元、商品适配欧洲)。

在模型 2 中:

  • Gateway 可以这样判断:locale = "en" → 英文服务,但 country = "DE" → 商品来自欧洲仓;根据你的业务逻辑可以:
  • 要么把请求发到 mcp-giftgenius-en 并附带 country=DE
  • 要么单独维护一个 mcp-giftgenius-eu

由此可见,locale(语言)与 region(userLocation)是两个维度;Gateway 是把二者“拼成”路由决策(调用哪个服务、展示哪些商品)的理想位置。

8. 在工具 schema 中放 locale,还是只放在 _meta

无论你用的是单个 MCP,还是 Gateway + 单语服务,最后都要回答一个微妙但重要的问题:只在 _meta 中携带 locale,还是把它做成工具的参数?

有两种做法。

第一种:只依赖 _meta

好处是工具 schema 不会被再添一个字段搞得繁琐。服务器从 extra._meta 读取 locale 自行决策。在模型 1 中通常足够。

第二种:locale(以及可能的 currency)显式加入工具的 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"]
};

接着你可以在 system‑prompt 里要求模型总是填充 locale 参数,使用用户上下文中的值。这样意图更透明:JSON 参数里就明确声明了服务器应使用的语言。对于较复杂的架构(例如一个公共 MCP 会根据 locale 路由到不同服务或资源)尤为有用。

实践中常用折中方案:schema 里有 locale 字段,但如果模型没填,服务器会回退读取 _meta["openai/locale"]。

9. 本地化与 Gateway 中“过度逻辑”的边界

一个常见陷阱是:既然我们有了“聪明”的 Gateway,不如让它:

  • 自己决定展示哪些礼物,
  • 自己格式化日期与价格,
  • 自己汇总点击报表,等等。

这听起来很诱人,但会把 Gateway 变成“第二个单体”,从而让更新与运维更困难。在业界的 API 网关实践中(MCP Gateway 的角色与之类似),网关应聚焦在:认证、授权、路由与轻量的上下文增强。例如,网关可以把 HTTP 头转换成更便于使用的元数据。业务逻辑与重活应放在后端服务里。

对本地化而言,这意味着:

  • Gateway 可以解析 _meta["openai/locale"] 与 _meta["openai/userLocation"];
  • 可以把它们记入客户端状态;
  • 可以选择合适的语言服务器,或在请求里补充 locale/country 字段。

但礼物的具体挑选、按年龄和预算的过滤等——都应留在 MCP 后端服务中完成。

10. 通过 MCP 和 Gateway 设计本地化时的常见错误

错误 1:只靠对用户文本做语言检测来“猜”语言。
有时会想拿到用户消息后跑一遍 language detector,再据此决定调用哪个服务。这或许可以作为兜底,但不应成为主机制。平台已经提供了 openai/localeopenai/userLocation,它们综合了 ChatGPT 设置与用户环境。忽视这些信号、靠“猜语言”是把 UX 搞砸的捷径。

错误 2:只把 locale 放在模型“心里”,却不传到服务器。
如果 locale 既不出现在 _meta,也不在工具参数里,服务器就不了解用户语言。模型也许会把字符串“knigi”翻成 books,但这不可靠,尤其是你的分类很复杂时。正确做法是显式传递 locale:要么作为 locale 参数,要么从 _meta 读取,并围绕它设计架构。

错误 3:把所有本地化业务逻辑搬到 Gateway。
如果 Gateway 自己挑礼物、查数据库、调用外部 API,它就不再是轻量路由器,而是成了难以扩展与更新的重服务。结果是你从一个单体变成两个单体。请尽量让 Gateway “又傻又轻”:只看 locale/userLocation,选后端,并把元数据干净地向下传递。

错误 4:路由只硬绑 IP 或 userLocation。
有时会想简单点:“国家是 RU 就走 RU 服务器”。但用户可能人虽在德国,却想要俄语界面,或者他可能在会话中途说“switch to English”。如果 Gateway 不考虑 openai/locale 与用户随时切换语言的意愿,路由就会“僵死”,破坏 UX。更好的做法是同时参考 locale 与 userLocation,并支持通过会话状态覆盖设置。

错误 5:不使用 _meta["openai/subject"],把所有参数在每个调用里重复一遍。
当你在每个工具参数里都携带 localecountrycurrencyuserId 等一大堆字段时,开发会很痛苦。MCP 已经通过 _meta["openai/subject"] 传递了匿名用户标识,你可以把这些信息放在 Gateway 或后端服务的客户端状态里,从而简化契约、降低参数不同步的风险。

错误 6:缺乏演进策略:一开始就要搭一个支持十种语言的复杂 Gateway。
常见的冲动是“一步到位”:Gateway、五种语言、三个地区、十个 MCP 服务。实践中更简单的路径是从“单个 MCP + locale 参数或 _meta”开始,把行为打磨稳定,然后随着增长再抽出 Gateway 与单语服务。试图一口气造出庞大“动物园”,往往会拖慢发布并让调试变得复杂。

1
调查/小测验
本地化第 9 级,课程 4
不可用
本地化
本地化(UI、数据、工具描述)
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION