1. 为什么要考虑本地化架构
当你只有一种语言且目录很小,一切都很简单:你维护一个 gift_catalog.json,所有文本都是俄语,MCP‑服务器会如实把这些礼物返回给所有用户。但一旦你希望:
- 面向美国和欧洲的英文 UI,
- 单独的俄语目录(包含套娃和俄语书籍),
- 不同市场(美国用 Amazon,俄罗斯用 Ozon),
天真的做法是在每个 handler 里再加一个 if(locale === "ru"),最终会把代码写成“圣诞树式分支”。
MCP 一方面是协议,另一方面是该协议的服务器实现。服务器会接收来自 ChatGPT 的请求及其元数据,其中包括 locale 和 userLocation。问题不在于“能不能读到 locale”,而在于你在架构的哪个位置利用这个信号。可以在每个工具里处理,也可以把一部分逻辑抽到单独一层——Gateway。
一个好的本地化架构需要回答三个问题:
- 我们在哪里决定使用哪种语言与地区。
- 我们在哪里选择所需的数据与集成(目录、商店 API、货币)。
- 我们在哪里以及如何保存用户状态(locale、货币,乃至一些偏好),以避免每次都手动传递。
今天我们就来拆解这些。
2. MCP、_meta 与无状态特性:为什么必须显式传递 locale
在决定架构中哪里处理 locale 之前,先回顾一下协议层面的 MCP 请求长什么样,以及平台已经帮你传了哪些元数据。
要点提醒:MCP 请求是 JSON‑RPC 消息。每条消息都是独立的,协议并不强制有状态会话。因此,如果你希望服务器考虑本地化,就需要要么:
- 把它作为工具参数显式传入(locale 在 inputSchema 中),要么
- 从 _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、一次部署、一套代码。在每个工具内部你:
- 获取 locale(来自 _meta 或参数)。
- 基于 locale 选择资源:gift_catalog.en.json、gift_catalog.ru.json 等。
- 用正确的语言返回结果。
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 变成“装满业务逻辑的第二个单体”。在本模块里它的职责非常克制:
- 接收来自 ChatGPT 的 MCP 消息(包含 _meta)。
- 提取 locale / userLocation。
- 据此选择目标后端服务器。
- 把请求(JSON‑RPC)代理过去,并把响应转回。
至于选哪本礼物目录、如何调用 Amazon 或 Ozon,都留在具体的语言 MCP 服务器里。Gateway 不需要知道“给某位长辈的完美礼物”。它只需要知道:ru-RU 去 mcp-giftgenius-ru,en-US 去 mcp-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 也可以同样代理 listTools、listResources 等 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);
}
);
这样,你只需一次把 clientId → locale、country 这组信息“记住”,随后在所有工具调用中都能复用,无需在每个参数里到处复制。
同理,Gateway 还能记住偏好的货币、价格格式或其他对电商逻辑有用的设置(更多会在 ACP 模块中讨论)。
7. GiftGenius:两个场景与架构选择的影响
为了不陷入抽象的“方框图”,我们来看 GiftGenius 的两个具体场景。
场景 1:来自俄罗斯的用户,用俄语交流
假设:
- _meta["openai/locale"] = "ru-RU",
- _meta["openai/userLocation"].country = "RU"。
用户发来消息:“给同事挑个礼物,他喜欢桌游,预算 3000 卢布以内”。
在模型 1(单个 MCP)中:
- handler 从 _meta 读取 locale,得到 "ru-RU";
- 加载 gift_catalog.ru.json,其中名称是俄语、价格为卢布;
- 按类别与预算过滤,返回俄语的结构化礼物列表。
在模型 2(Gateway + 单语服务)中:
- Gateway 读取 locale 和 userLocation,判断该用户属于 RU;
- 把 suggest_gifts 调用路由到 mcp-giftgenius-ru;
- 该服务只处理俄语目录与 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/locale 和 openai/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"],把所有参数在每个调用里重复一遍。
当你在每个工具参数里都携带 locale、country、currency、userId 等一大堆字段时,开发会很痛苦。MCP 已经通过 _meta["openai/subject"] 传递了匿名用户标识,你可以把这些信息放在 Gateway 或后端服务的客户端状态里,从而简化契约、降低参数不同步的风险。
错误 6:缺乏演进策略:一开始就要搭一个支持十种语言的复杂 Gateway。
常见的冲动是“一步到位”:Gateway、五种语言、三个地区、十个 MCP 服务。实践中更简单的路径是从“单个 MCP + locale 参数或 _meta”开始,把行为打磨稳定,然后随着增长再抽出 Gateway 与单语服务。试图一口气造出庞大“动物园”,往往会拖慢发布并让调试变得复杂。
GO TO FULL VERSION