Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions examples/client/src/i18nClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* SEP-2792 i18n Example Client
*
* Demonstrates per-request language negotiation from the client side.
* Connects to the i18n example server and exercises three language scenarios:
* 1. "en" — explicit English
* 2. "fr-CA,fr;q=0.9,en;q=0.5" — French Canadian with fallback
* 3. "ja" — Japanese (forces fallback to server default)
*
* Run with HTTP: tsx src/i18nClient.ts http
* Run with stdio: tsx src/i18nClient.ts stdio
*/

import {
ACCEPT_LANGUAGE_META,
Client,
CONTENT_LANGUAGE_META,
getErrorContentLanguage,
StreamableHTTPClientTransport
} from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';

const TEST_LANGUAGES = ['en', 'fr-CA,fr;q=0.9,en;q=0.5', 'ja'];

async function runWithTransport(
transport: InstanceType<typeof StreamableHTTPClientTransport> | InstanceType<typeof StdioClientTransport>
): Promise<void> {
const client = new Client({ name: 'i18n-example-client', version: '1.0.0' });
await client.connect(transport);

console.log('=== SEP-2792 i18n Client Demo ===\n');

for (const lang of TEST_LANGUAGES) {
console.log(`--- Accept-Language: "${lang}" ---`);

// List tools with language preference
const listResult = await client.listTools({
_meta: { [ACCEPT_LANGUAGE_META]: lang }
});

const tool = listResult.tools[0];
const listContentLang = listResult._meta?.[CONTENT_LANGUAGE_META];
console.log(` tools/list → title: "${tool?.title}", description: "${tool?.description}"`);
console.log(` contentLanguage: "${listContentLang}"`);

// Call the tool with language preference
const callResult = await client.callTool({
name: 'get_greeting',
arguments: { name: 'World' },
_meta: { [ACCEPT_LANGUAGE_META]: lang }
});

const text = callResult.content?.[0]?.type === 'text' ? callResult.content[0].text : '(no text)';
const callContentLang = callResult._meta?.[CONTENT_LANGUAGE_META];
console.log(` tools/call → text: "${text}"`);
console.log(` contentLanguage: "${callContentLang}"`);

// Demonstrate localized error: call with empty name
try {
await client.callTool({
name: 'get_greeting',
arguments: { name: '' },
_meta: { [ACCEPT_LANGUAGE_META]: lang }
});
} catch (error: unknown) {
const err = error as { code?: number; message?: string; data?: unknown };
const errorLang = getErrorContentLanguage(err.data);
console.log(` tools/call (error) → message: "${err.message}"`);
console.log(` contentLanguage: "${errorLang}"`);
}
console.log('');
}

await client.close();
}

// ---------- Main ----------

const mode = process.argv[2] || 'stdio';
if (mode === 'http') {
const url = process.env.MCP_URL ?? 'http://localhost:3456/mcp';
const transport = new StreamableHTTPClientTransport(new URL(url));
await runWithTransport(transport);
} else {
const transport = new StdioClientTransport({
command: 'tsx',
args: [new URL('../../../server/src/i18nExample.ts', import.meta.url).pathname, 'stdio']
});
await runWithTransport(transport);
}
186 changes: 186 additions & 0 deletions examples/server/src/i18nExample.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
/**
* SEP-2792 i18n Example Server
*
* Demonstrates per-request language negotiation using the MCP i18n helpers.
* Supports three languages (en, fr, de) and exposes a `get_greeting` tool
* with localized title, description, and response content.
*
* Run via stdio: tsx src/i18nExample.ts stdio
* Run via HTTP: tsx src/i18nExample.ts http
*/

import { createMcpExpressApp } from '@modelcontextprotocol/express';
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
import type { CallToolResult, ListToolsResult } from '@modelcontextprotocol/server';
import {
ACCEPT_LANGUAGE_META,
getAcceptLanguage,
McpServer,
negotiateLanguage,
ProtocolError,
setContentLanguage,
setErrorContentLanguage
} from '@modelcontextprotocol/server';
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
import * as z from 'zod/v4';

// ---------- Localization dictionaries ----------

const AVAILABLE_LANGUAGES = ['en', 'fr', 'de'];

const STRINGS: Record<string, Record<string, string>> = {
'tool.get_greeting.title': {
en: 'Get Greeting',
fr: 'Obtenir un salut',
de: 'Begrüßung erhalten'
},
'tool.get_greeting.description': {
en: 'Returns a greeting in the negotiated language',
fr: 'Retourne un salut dans la langue négociée',
de: 'Gibt eine Begrüßung in der ausgehandelten Sprache zurück'
},
greeting: {
en: 'Hello, {name}! Welcome.',
fr: 'Bonjour, {name} ! Bienvenue.',
de: 'Hallo, {name}! Willkommen.'
},
'error.name_required': {
en: 'A name is required to generate a greeting.',
fr: 'Un nom est requis pour générer un salut.',
de: 'Ein Name ist erforderlich, um eine Begrüßung zu erzeugen.'
}
};

function t(key: string, lang: string, replacements?: Record<string, string>): string {
let template = STRINGS[key]?.[lang] ?? STRINGS[key]?.['en'] ?? key;
if (!replacements) return template;
for (const [k, v] of Object.entries(replacements)) {
template = template.replace(`{${k}}`, v);
}
return template;
}

// ---------- Server setup ----------

function createI18nServer(): McpServer {
const server = new McpServer(
{
name: 'i18n-example-server',
version: '1.0.0'
},
{ capabilities: { tools: {} } }
);

// Override tools/list to support per-request localized metadata
server.server.setRequestHandler('tools/list', (request, ctx): ListToolsResult => {
const acceptLang = ctx.mcpReq._meta?.[ACCEPT_LANGUAGE_META] as string | undefined;
const lang = negotiateLanguage(acceptLang ?? '', AVAILABLE_LANGUAGES, 'en')!;

const result: ListToolsResult = {
tools: [
{
name: 'get_greeting',
title: t('tool.get_greeting.title', lang),
description: t('tool.get_greeting.description', lang),
inputSchema: {
type: 'object' as const,
properties: {
name: { type: 'string', description: 'Name to greet' }
},
required: ['name']
}
}
]
};
setContentLanguage(result, lang);
return result;
});

// Register the tool for tools/call via McpServer
server.registerTool(
'get_greeting',
{
title: 'Get Greeting',
description: 'Returns a greeting in the negotiated language',
inputSchema: z.object({
name: z.string().describe('Name to greet')
})
},
async ({ name }, ctx): Promise<CallToolResult> => {
const acceptLang = getAcceptLanguage(ctx.mcpReq as { _meta?: Record<string, unknown> }) ?? '';
const lang = negotiateLanguage(acceptLang, AVAILABLE_LANGUAGES, 'en')!;

// Demonstrate localized error: empty name triggers a localized error response
if (!name || name.trim() === '') {
const errorMessage = t('error.name_required', lang);
const errorData = setErrorContentLanguage({}, lang);
throw new ProtocolError(-32_602, errorMessage, errorData);
}

const result: CallToolResult = {
content: [
{
type: 'text',
text: t('greeting', lang, { name })
}
]
};
setContentLanguage(result, lang);
return result;
}
);

return server;
}

// ---------- Transport entry points ----------

// ---------- Main ----------

const mode = process.argv[2] || 'stdio';
if (mode === 'http') {
const app = createMcpExpressApp();

app.post('/mcp', async (req, res) => {
const server = createI18nServer();
const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: undefined // stateless
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
res.on('close', () => {
transport.close();
server.close();
});
});

app.get('/mcp', (_req, res) => {
res.writeHead(405).end(
JSON.stringify({
jsonrpc: '2.0',
error: { code: -32_000, message: 'Method not allowed.' },
id: null
})
);
});

app.delete('/mcp', (_req, res) => {
res.writeHead(405).end(
JSON.stringify({
jsonrpc: '2.0',
error: { code: -32_000, message: 'Method not allowed.' },
id: null
})
);
});

const PORT = Number.parseInt(process.env.PORT ?? '3456', 10);
app.listen(PORT, () => {
console.error(`i18n example server running on http://localhost:${PORT}/mcp`);
});
} else {
const server = createI18nServer();
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('i18n example server running on stdio');
}
23 changes: 23 additions & 0 deletions packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ReadableWritablePair } from 'node:stream/web';

import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol/core';
import {
ACCEPT_LANGUAGE_META,
createFetchWithInit,
isInitializedNotification,
isJSONRPCErrorResponse,
Expand Down Expand Up @@ -231,6 +232,21 @@ export class StreamableHTTPClientTransport implements Transport {
});
}

/**
* Extracts the acceptLanguage value from a message's _meta for header mirroring (SEP-2792).
*/
private _extractAcceptLanguage(message: JSONRPCMessage | JSONRPCMessage[]): string | undefined {
const msg = Array.isArray(message) ? message[0] : message;
if (!msg) return undefined;
if ('params' in msg && msg.params && typeof msg.params === 'object') {
const meta = (msg.params as { _meta?: Record<string, unknown> })._meta;
if (meta && typeof meta[ACCEPT_LANGUAGE_META] === 'string') {
return meta[ACCEPT_LANGUAGE_META] as string;
}
}
return undefined;
}

private async _startOrAuthSse(options: StartSSEOptions, isAuthRetry = false): Promise<void> {
const { resumptionToken } = options;

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

// SEP-2792: Best-effort mirror of acceptLanguage from _meta to Accept-Language header.
// If the caller already set Accept-Language manually, _meta takes precedence.
const metaAcceptLanguage = this._extractAcceptLanguage(message);
if (metaAcceptLanguage) {
headers.set('accept-language', metaAcceptLanguage);
}

const init = {
...this._requestInit,
method: 'POST',
Expand Down
7 changes: 4 additions & 3 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"client": "tsx scripts/cli.ts client"
},
"dependencies": {
"@formatjs/intl-localematcher": "^0.8.8",
"ajv": "catalog:runtimeShared",
"ajv-formats": "catalog:runtimeShared",
"json-schema-typed": "catalog:runtimeShared",
Expand All @@ -67,11 +68,11 @@
}
},
"devDependencies": {
"@modelcontextprotocol/tsconfig": "workspace:^",
"@modelcontextprotocol/vitest-config": "workspace:^",
"@modelcontextprotocol/eslint-config": "workspace:^",
"@cfworker/json-schema": "catalog:runtimeShared",
"@eslint/js": "catalog:devTools",
"@modelcontextprotocol/eslint-config": "workspace:^",
"@modelcontextprotocol/tsconfig": "workspace:^",
"@modelcontextprotocol/vitest-config": "workspace:^",
"@types/content-type": "catalog:devTools",
"@types/cors": "catalog:devTools",
"@types/cross-spawn": "catalog:devTools",
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/exports/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,19 @@ export type { FetchLike, Transport, TransportSendOptions } from '../../shared/tr
export { createFetchWithInit } from '../../shared/transport.js';
export { InMemoryTransport } from '../../util/inMemory.js';

// i18n helpers (SEP-2792)
export {
ACCEPT_LANGUAGE_META,
CONTENT_LANGUAGE_META,
getAcceptLanguage,
getContentLanguage,
getErrorContentLanguage,
negotiateLanguage,
setAcceptLanguage,
setContentLanguage,
setErrorContentLanguage
} from '../../shared/i18n.js';

// URI Template
export type { Variables } from '../../shared/uriTemplate.js';
export { UriTemplate } from '../../shared/uriTemplate.js';
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * from './auth/errors.js';
export * from './errors/sdkErrors.js';
export * from './shared/auth.js';
export * from './shared/authUtils.js';
export * from './shared/i18n.js';
export * from './shared/metadataUtils.js';
export * from './shared/protocol.js';
export * from './shared/responseMessage.js';
Expand Down
Loading
Loading