Skip to content

Commit cbd2d60

Browse files
feat: SEP-2792 reference implementation for per-request language negotiation
Implements internationalization via per-request language negotiation as specified in SEP-2792. Changes include: SDK changes: - Add i18n helper module (packages/core/src/shared/i18n.ts) with: - ACCEPT_LANGUAGE_META / CONTENT_LANGUAGE_META constants - getAcceptLanguage / setAcceptLanguage helpers for request _meta - getContentLanguage / setContentLanguage helpers for response _meta - negotiateLanguage() using @formatjs/intl-localematcher (RFC 4647) - Client Streamable HTTP transport: mirrors _meta acceptLanguage to Accept-Language header; throws on header/body mismatch - Server Streamable HTTP transport: validates Accept-Language header vs _meta (400 on mismatch), copies header→_meta when only header present, mirrors Content-Language header from response _meta on JSON responses Examples: - examples/server/src/i18nExample.ts: server with get_greeting tool supporting en/fr/de via stdio and HTTP transports - examples/client/src/i18nClient.ts: client demonstrating three language scenarios (exact, fallback chain, no-match fallback) Tests: - Unit tests for all helpers and negotiateLanguage (quality values, subtag matching, fallback behavior) - HTTP integration tests: header mirroring, Content-Language on response, 400 on mismatch, agreement pass-through - stdio integration test: mid-session language switching on same connection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5fc42e9 commit cbd2d60

11 files changed

Lines changed: 900 additions & 11 deletions

File tree

examples/client/src/i18nClient.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* SEP-2792 i18n Example Client
3+
*
4+
* Demonstrates per-request language negotiation from the client side.
5+
* Connects to the i18n example server and exercises three language scenarios:
6+
* 1. "en" — explicit English
7+
* 2. "fr-CA,fr;q=0.9,en;q=0.5" — French Canadian with fallback
8+
* 3. "ja" — Japanese (forces fallback to server default)
9+
*
10+
* Run with HTTP: tsx src/i18nClient.ts http
11+
* Run with stdio: tsx src/i18nClient.ts stdio
12+
*/
13+
14+
import { ACCEPT_LANGUAGE_META, Client, CONTENT_LANGUAGE_META, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
15+
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
16+
17+
const TEST_LANGUAGES = ['en', 'fr-CA,fr;q=0.9,en;q=0.5', 'ja'];
18+
19+
async function runWithTransport(
20+
transport: InstanceType<typeof StreamableHTTPClientTransport> | InstanceType<typeof StdioClientTransport>
21+
): Promise<void> {
22+
const client = new Client({ name: 'i18n-example-client', version: '1.0.0' });
23+
await client.connect(transport);
24+
25+
console.log('=== SEP-2792 i18n Client Demo ===\n');
26+
27+
for (const lang of TEST_LANGUAGES) {
28+
console.log(`--- Accept-Language: "${lang}" ---`);
29+
30+
// List tools with language preference
31+
const listResult = await client.listTools({
32+
_meta: { [ACCEPT_LANGUAGE_META]: lang }
33+
});
34+
35+
const tool = listResult.tools[0];
36+
const listContentLang = listResult._meta?.[CONTENT_LANGUAGE_META];
37+
console.log(` tools/list → title: "${tool?.title}", description: "${tool?.description}"`);
38+
console.log(` contentLanguage: "${listContentLang}"`);
39+
40+
// Call the tool with language preference
41+
const callResult = await client.callTool({
42+
name: 'get_greeting',
43+
arguments: { name: 'World' },
44+
_meta: { [ACCEPT_LANGUAGE_META]: lang }
45+
});
46+
47+
const text = callResult.content?.[0]?.type === 'text' ? callResult.content[0].text : '(no text)';
48+
const callContentLang = callResult._meta?.[CONTENT_LANGUAGE_META];
49+
console.log(` tools/call → text: "${text}"`);
50+
console.log(` contentLanguage: "${callContentLang}"`);
51+
console.log('');
52+
}
53+
54+
await client.close();
55+
}
56+
57+
// ---------- Main ----------
58+
59+
const mode = process.argv[2] || 'stdio';
60+
if (mode === 'http') {
61+
const url = process.env.MCP_URL ?? 'http://localhost:3456/mcp';
62+
const transport = new StreamableHTTPClientTransport(new URL(url));
63+
await runWithTransport(transport);
64+
} else {
65+
const transport = new StdioClientTransport({
66+
command: 'tsx',
67+
args: [new URL('../../../server/src/i18nExample.ts', import.meta.url).pathname, 'stdio']
68+
});
69+
await runWithTransport(transport);
70+
}

examples/server/src/i18nExample.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/**
2+
* SEP-2792 i18n Example Server
3+
*
4+
* Demonstrates per-request language negotiation using the MCP i18n helpers.
5+
* Supports three languages (en, fr, de) and exposes a `get_greeting` tool
6+
* with localized title, description, and response content.
7+
*
8+
* Run via stdio: tsx src/i18nExample.ts stdio
9+
* Run via HTTP: tsx src/i18nExample.ts http
10+
*/
11+
12+
import { createMcpExpressApp } from '@modelcontextprotocol/express';
13+
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
14+
import type { CallToolResult, ListToolsResult } from '@modelcontextprotocol/server';
15+
import { ACCEPT_LANGUAGE_META, getAcceptLanguage, McpServer, negotiateLanguage, setContentLanguage } from '@modelcontextprotocol/server';
16+
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
17+
import * as z from 'zod/v4';
18+
19+
// ---------- Localization dictionaries ----------
20+
21+
const AVAILABLE_LANGUAGES = ['en', 'fr', 'de'];
22+
23+
const STRINGS: Record<string, Record<string, string>> = {
24+
'tool.get_greeting.title': {
25+
en: 'Get Greeting',
26+
fr: 'Obtenir un salut',
27+
de: 'Begrüßung erhalten'
28+
},
29+
'tool.get_greeting.description': {
30+
en: 'Returns a greeting in the negotiated language',
31+
fr: 'Retourne un salut dans la langue négociée',
32+
de: 'Gibt eine Begrüßung in der ausgehandelten Sprache zurück'
33+
},
34+
greeting: {
35+
en: 'Hello, {name}! Welcome.',
36+
fr: 'Bonjour, {name} ! Bienvenue.',
37+
de: 'Hallo, {name}! Willkommen.'
38+
}
39+
};
40+
41+
function t(key: string, lang: string, replacements?: Record<string, string>): string {
42+
let template = STRINGS[key]?.[lang] ?? STRINGS[key]?.['en'] ?? key;
43+
if (!replacements) return template;
44+
for (const [k, v] of Object.entries(replacements)) {
45+
template = template.replace(`{${k}}`, v);
46+
}
47+
return template;
48+
}
49+
50+
// ---------- Server setup ----------
51+
52+
function createI18nServer(): McpServer {
53+
const server = new McpServer(
54+
{
55+
name: 'i18n-example-server',
56+
version: '1.0.0'
57+
},
58+
{ capabilities: { tools: {} } }
59+
);
60+
61+
// Override tools/list to support per-request localized metadata
62+
server.server.setRequestHandler('tools/list', (request, ctx): ListToolsResult => {
63+
const acceptLang = ctx.mcpReq._meta?.[ACCEPT_LANGUAGE_META] as string | undefined;
64+
const lang = negotiateLanguage(acceptLang ?? '', AVAILABLE_LANGUAGES, 'en')!;
65+
66+
const result: ListToolsResult = {
67+
tools: [
68+
{
69+
name: 'get_greeting',
70+
title: t('tool.get_greeting.title', lang),
71+
description: t('tool.get_greeting.description', lang),
72+
inputSchema: {
73+
type: 'object' as const,
74+
properties: {
75+
name: { type: 'string', description: 'Name to greet' }
76+
},
77+
required: ['name']
78+
}
79+
}
80+
]
81+
};
82+
setContentLanguage(result, lang);
83+
return result;
84+
});
85+
86+
// Register the tool for tools/call via McpServer
87+
server.registerTool(
88+
'get_greeting',
89+
{
90+
title: 'Get Greeting',
91+
description: 'Returns a greeting in the negotiated language',
92+
inputSchema: z.object({
93+
name: z.string().describe('Name to greet')
94+
})
95+
},
96+
async ({ name }, ctx): Promise<CallToolResult> => {
97+
const acceptLang = getAcceptLanguage(ctx.mcpReq as { _meta?: Record<string, unknown> }) ?? '';
98+
const lang = negotiateLanguage(acceptLang, AVAILABLE_LANGUAGES, 'en')!;
99+
100+
const result: CallToolResult = {
101+
content: [
102+
{
103+
type: 'text',
104+
text: t('greeting', lang, { name })
105+
}
106+
]
107+
};
108+
setContentLanguage(result, lang);
109+
return result;
110+
}
111+
);
112+
113+
return server;
114+
}
115+
116+
// ---------- Transport entry points ----------
117+
118+
// ---------- Main ----------
119+
120+
const mode = process.argv[2] || 'stdio';
121+
if (mode === 'http') {
122+
const app = createMcpExpressApp();
123+
124+
app.post('/mcp', async (req, res) => {
125+
const server = createI18nServer();
126+
const transport = new NodeStreamableHTTPServerTransport({
127+
sessionIdGenerator: undefined // stateless
128+
});
129+
await server.connect(transport);
130+
await transport.handleRequest(req, res, req.body);
131+
res.on('close', () => {
132+
transport.close();
133+
server.close();
134+
});
135+
});
136+
137+
app.get('/mcp', (_req, res) => {
138+
res.writeHead(405).end(
139+
JSON.stringify({
140+
jsonrpc: '2.0',
141+
error: { code: -32_000, message: 'Method not allowed.' },
142+
id: null
143+
})
144+
);
145+
});
146+
147+
app.delete('/mcp', (_req, res) => {
148+
res.writeHead(405).end(
149+
JSON.stringify({
150+
jsonrpc: '2.0',
151+
error: { code: -32_000, message: 'Method not allowed.' },
152+
id: null
153+
})
154+
);
155+
});
156+
157+
const PORT = Number.parseInt(process.env.PORT ?? '3456', 10);
158+
app.listen(PORT, () => {
159+
console.error(`i18n example server running on http://localhost:${PORT}/mcp`);
160+
});
161+
} else {
162+
const server = createI18nServer();
163+
const transport = new StdioServerTransport();
164+
await server.connect(transport);
165+
console.error('i18n example server running on stdio');
166+
}

packages/client/src/client/streamableHttp.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ReadableWritablePair } from 'node:stream/web';
22

33
import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol/core';
44
import {
5+
ACCEPT_LANGUAGE_META,
56
createFetchWithInit,
67
isInitializedNotification,
78
isJSONRPCErrorResponse,
@@ -231,6 +232,28 @@ export class StreamableHTTPClientTransport implements Transport {
231232
});
232233
}
233234

235+
/**
236+
* Extracts the acceptLanguage value from message(s) _meta for header mirroring (SEP-2792).
237+
* For batched messages with differing values, returns the union of language ranges.
238+
*/
239+
private _extractAcceptLanguage(message: JSONRPCMessage | JSONRPCMessage[]): string | undefined {
240+
const messages = Array.isArray(message) ? message : [message];
241+
const values: string[] = [];
242+
for (const msg of messages) {
243+
if ('params' in msg && msg.params && typeof msg.params === 'object') {
244+
const meta = (msg.params as { _meta?: Record<string, unknown> })._meta;
245+
if (meta && typeof meta[ACCEPT_LANGUAGE_META] === 'string') {
246+
values.push(meta[ACCEPT_LANGUAGE_META] as string);
247+
}
248+
}
249+
}
250+
if (values.length === 0) return undefined;
251+
// For batched messages with different values, union the language ranges
252+
if (values.length === 1) return values[0];
253+
const unique = [...new Set(values)];
254+
return unique.length === 1 ? unique[0] : unique.join(', ');
255+
}
256+
234257
private async _startOrAuthSse(options: StartSSEOptions, isAuthRetry = false): Promise<void> {
235258
const { resumptionToken } = options;
236259

@@ -546,6 +569,19 @@ export class StreamableHTTPClientTransport implements Transport {
546569
const types = [...(userAccept?.split(',').map(s => s.trim().toLowerCase()) ?? []), 'application/json', 'text/event-stream'];
547570
headers.set('accept', [...new Set(types)].join(', '));
548571

572+
// SEP-2792: Mirror acceptLanguage from _meta to Accept-Language header
573+
const metaAcceptLanguage = this._extractAcceptLanguage(message);
574+
if (metaAcceptLanguage) {
575+
const existingHeader = headers.get('accept-language');
576+
if (existingHeader && existingHeader !== metaAcceptLanguage) {
577+
throw new SdkError(
578+
SdkErrorCode.SendFailed,
579+
`Accept-Language header "${existingHeader}" conflicts with _meta["${ACCEPT_LANGUAGE_META}"] value "${metaAcceptLanguage}". They must be identical per SEP-2792.`
580+
);
581+
}
582+
headers.set('accept-language', metaAcceptLanguage);
583+
}
584+
549585
const init = {
550586
...this._requestInit,
551587
method: 'POST',

packages/core/package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"client": "tsx scripts/cli.ts client"
5050
},
5151
"dependencies": {
52+
"@formatjs/intl-localematcher": "^0.8.8",
5253
"ajv": "catalog:runtimeShared",
5354
"ajv-formats": "catalog:runtimeShared",
5455
"json-schema-typed": "catalog:runtimeShared",
@@ -67,11 +68,11 @@
6768
}
6869
},
6970
"devDependencies": {
70-
"@modelcontextprotocol/tsconfig": "workspace:^",
71-
"@modelcontextprotocol/vitest-config": "workspace:^",
72-
"@modelcontextprotocol/eslint-config": "workspace:^",
7371
"@cfworker/json-schema": "catalog:runtimeShared",
7472
"@eslint/js": "catalog:devTools",
73+
"@modelcontextprotocol/eslint-config": "workspace:^",
74+
"@modelcontextprotocol/tsconfig": "workspace:^",
75+
"@modelcontextprotocol/vitest-config": "workspace:^",
7576
"@types/content-type": "catalog:devTools",
7677
"@types/cors": "catalog:devTools",
7778
"@types/cross-spawn": "catalog:devTools",

packages/core/src/exports/public/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,17 @@ export type { FetchLike, Transport, TransportSendOptions } from '../../shared/tr
7474
export { createFetchWithInit } from '../../shared/transport.js';
7575
export { InMemoryTransport } from '../../util/inMemory.js';
7676

77+
// i18n helpers (SEP-2792)
78+
export {
79+
ACCEPT_LANGUAGE_META,
80+
CONTENT_LANGUAGE_META,
81+
getAcceptLanguage,
82+
getContentLanguage,
83+
negotiateLanguage,
84+
setAcceptLanguage,
85+
setContentLanguage
86+
} from '../../shared/i18n.js';
87+
7788
// URI Template
7889
export type { Variables } from '../../shared/uriTemplate.js';
7990
export { UriTemplate } from '../../shared/uriTemplate.js';

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export * from './auth/errors.js';
22
export * from './errors/sdkErrors.js';
33
export * from './shared/auth.js';
44
export * from './shared/authUtils.js';
5+
export * from './shared/i18n.js';
56
export * from './shared/metadataUtils.js';
67
export * from './shared/protocol.js';
78
export * from './shared/responseMessage.js';

0 commit comments

Comments
 (0)