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 訊息。每個訊息都是獨立的,協定不強迫你用 stateful 的工作階段。因此,如果你希望伺服器考量在地化,必須要嘛:
- 把它顯式當成工具參數傳入(inputSchema 中的 locale),要嘛
- 從 ChatGPT 加到請求裡的 _meta["openai/locale"] 讀取。
以下是一個從 _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 也扮演關鍵角色:Gateway 讀取中繼資料中的 locale,並據此決定要把請求送到哪個後端。 至於要把 locale 只放在 _meta,或同時也放進工具 schema,稍後我們會獨立討論。
3. 模型一:單一多語 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 只要呼叫這個幫手即可:
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. 模型二:MCP Gateway + 單語後端伺服器
現在想像 GiftGenius 同時在美國、俄羅斯與德國運作。美國連 Amazon API,俄羅斯連 Ozon,德國連在地零售商。每個市場各有其契約、特性與團隊。把全部硬塞進單一 MCP 單體並不理想。
模型二的想法是:
在 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。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。
最簡 MCP Gateway 骨架(TypeScript)
為了不陷入細節,我們大幅簡化。假設我們有個幫手 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 與各後端服務的邊界 |
在本課程中,我們主要採用模型一(單一 MCP + locale 作為參數),而將 Gateway 視為當你有「真正的業務」且跨多市場時的自然擴充路徑。 既然 Gateway 是自然的下一步,我們就來看這個架構中的一個關鍵細節:如何把使用者的 locale 與國家放入工作階段狀態中。
6. 在 Gateway 內將 locale 作為用戶端狀態的一部分
到目前為止我們假設每個請求都帶齊所有資訊。但在真實世界,讓部分資訊存放在工作階段狀態會更方便。比如:
- 使用者第一次帶著 locale = "ru-RU" 與 userLocation.country = "RU" 來;
- 之後你希望把他的所有請求都路由到 RU 後端,即便中間有些呼叫沒有在參數中顯式帶上 locale。
MCP 有個很實用的欄位 _meta["openai/subject"]——OpenAI 送到你服務的匿名使用者識別碼。可以把它當成工作階段的 key。
在記憶體中的簡單狀態實作
我們在 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 的具體情境。
情境一:使用者在俄羅斯,使用俄語書寫
條件如下:
- _meta["openai/locale"] = "ru-RU",
- _meta["openai/userLocation"].country = "RU"。
使用者訊息:「幫我挑一份送給同事的禮物,他喜歡桌遊,預算 3000 盧布以內」。
在模型一(單一 MCP):
- handler 從 _meta 讀到 "ru-RU" 的 locale。
- 載入 gift_catalog.ru.json,其中所有名稱為俄文、幣別為盧布。
- 依類別與預算過濾,回傳俄文的結構化禮物清單。
在模型二(Gateway + 單語服務):
- Gateway 讀取 locale 與 userLocation,判定為 RU 使用者。
- 把 suggest_gifts 呼叫導向 mcp-giftgenius-ru。
- 該服務只處理俄文目錄與 Ozon API,並以盧布回傳禮物。
兩種情況下使用者都會看到母語內容,但在第二種情況,你的英文 MCP 伺服器甚至完全不知道俄羅斯目錄的存在。
情境二:使用者在德國,使用英文書寫
條件如下:
- _meta["openai/locale"] = "en",
- _meta["openai/userLocation"].country = "DE"。
使用者訊息:「Gift for my German coworker, budget 50 EUR」。
在模型一:
- locale "en" 代表英文內容,
- 而 country "DE" 可以用來選擇歐洲區的目錄(以歐元計價且更符合當地品項)。
在模型二:
- Gateway 可以裁定:locale = "en" → 英文服務,但 country = "DE" → 用歐洲倉的商品;依你的商業邏輯,你可以:
- 要嘛把請求送到 mcp-giftgenius-en,並附上 country=DE,
- 要嘛維運獨立的歐洲服務 mcp-giftgenius-eu。
這裡可以清楚看見,語言(locale)與地區(userLocation)是不同維度;Gateway 是把兩者整合成「要叫哪個服務、要展示哪些商品」決策的好位置。
8. 在工具 schema 中放 locale,還是只用 _meta 的 locale?
無論你採用單一 MCP,還是 Gateway + 單語服務,最後都得面對一個重要但細膩的問題:把 locale 只放在 _meta,還是把它做成工具的參數?
做法有兩種。
第一種:只依賴 _meta。
這樣 schema 不會被多出來的欄位弄得雜亂。伺服器從 extra._meta 讀到 locale 後自行決策。在模型一中,這往往已足夠。
第二種:把 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. 在地化 vs Gateway「過度邏輯」的邊界在哪裡
很容易掉進一個陷阱:既然我們有個聰明的 Gateway,那就讓它:
- 自己決定要展示哪些禮物,
- 自己格式化日期與價格,
- 自己彙整點擊報表,等等。
這聽起來很迷人,但會把 Gateway 變成「第二個單體」,並讓更新與維運更困難。在產業中的 API Gateway 實務(MCP Gateway 的角色本質上就是個 Gateway)會把重心放在幾件事:驗證、授權、路由與輕量級的上下文增補。例如,Gateway 可以把 HTTP 標頭轉成好用的中繼資料。商業邏輯與繁重的操作應該留在後端服務。
對在地化而言,這代表:
- Gateway 可以剖析 _meta["openai/locale"] 與 _meta["openai/userLocation"]。
- 可以把它們記在用戶端狀態中。
- 可以選擇正確的語言伺服器,或替請求加上 locale/country 欄位。
但禮物挑選、依年齡或預算的過濾等——這些都應留在 MCP 後端服務內。
10. 透過 MCP 與 Gateway 設計在地化時的常見錯誤
錯誤一:只靠偵測使用者文字來「猜」語言。
有時會想把訊息文字丟進語言偵測器,然後據此決定要呼叫哪個服務。這可作為備援,但不應是主機制。平台已經提供 openai/locale 與 openai/userLocation,會考量 ChatGPT 設定與使用者環境。無視這些訊號、改玩「猜語言」,是讓 UX 在各種邊緣情境出錯的快捷方式。
錯誤二:只把 locale 放在模型腦海裡,而不傳到伺服器。
如果 locale 既不出現在 _meta,也不在工具參數中,伺服器就不知道使用者的語言。模型或許能把「книги」猜成 books,但這並不可靠,尤其在你有複雜分類時。正確做法是顯式傳遞 locale:或作為 locale 參數,或從 _meta 讀取,並圍繞它設計架構。
錯誤三:把所有在地化的商業邏輯搬進 Gateway。
若 Gateway 開始自己挑禮物、打資料庫、對接外部 API,它就不再是輕量路由器,而成了難以擴充與更新的重型服務。最後你會得到兩個單體。讓 Gateway 盡可能「笨」:看 locale/userLocation,選擇合適後端,並把中繼資料妥善傳遞即可。
錯誤四:路由只硬綁 IP 或 userLocation。
有時會想簡化成「如果國家是 RU 就去 RU 伺服器」。但使用者可能人在德國卻想要俄文介面,或在工作階段中突然說「switch to English」。如果 Gateway 不考量 openai/locale 與使用者可能想切換語言的需求,路由就會「硬梆梆」並破壞 UX。更好的做法是同時參考 locale 與 userLocation,並允許透過工作階段狀態覆寫。
錯誤五:不使用 _meta["openai/subject"],而在每個參數裡重複所有設定。
當你在每個工具參數裡都拖著 locale、country、currency、userId 等半個介面,日子會很難過。MCP 已經透過 _meta["openai/subject"] 傳遞匿名使用者 ID,你可以在 Gateway 或後端保存所有這些資訊。這能簡化契約,並降低參數不同步的風險。
錯誤六:沒有演進策略——一開始就要蓋一個支援十種語言的巨型 Gateway。
很多人一開始想做到完美:Gateway、五種語言、三個地區、十個 MCP 服務。實務上更簡單的路徑是:先用「單一 MCP + locale 參數或 _meta」把行為打穩,再隨著成長逐步抽出 Gateway 與單語服務。試圖一開始就建一座巨型「動物園」,幾乎保證會拖慢上線並讓偵錯變難。
GO TO FULL VERSION