diff --git a/examples/client/src/i18nClient.ts b/examples/client/src/i18nClient.ts new file mode 100644 index 0000000000..86557e4c9a --- /dev/null +++ b/examples/client/src/i18nClient.ts @@ -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 | InstanceType +): Promise { + 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); +} diff --git a/examples/server/src/i18nExample.ts b/examples/server/src/i18nExample.ts new file mode 100644 index 0000000000..2a39e884ec --- /dev/null +++ b/examples/server/src/i18nExample.ts @@ -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> = { + '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 { + 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 => { + const acceptLang = getAcceptLanguage(ctx.mcpReq as { _meta?: Record }) ?? ''; + 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'); +} diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 3b8ddafe5a..61b916d908 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -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, @@ -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 })._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 { const { resumptionToken } = options; @@ -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', diff --git a/packages/core/package.json b/packages/core/package.json index 201773736f..68f5a6a5a7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", @@ -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", diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index f73ab2d2e7..a12022e139 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -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'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8bcc9c9591..c1aaf851e2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/src/shared/i18n.ts b/packages/core/src/shared/i18n.ts new file mode 100644 index 0000000000..cd2a48dc37 --- /dev/null +++ b/packages/core/src/shared/i18n.ts @@ -0,0 +1,157 @@ +/** + * Internationalization helpers for SEP-2792: Per-Request Language Negotiation. + * + * Provides constants and utilities for reading/writing language preference + * metadata on MCP requests and responses, and for performing RFC 4647 + * language-range matching. + * + * @module i18n + */ + +import { match } from '@formatjs/intl-localematcher'; + +/** + * The `_meta` key for the client's language preference (request direction). + * Value syntax matches the HTTP `Accept-Language` field (RFC 9110 §12.5.4). + */ +export const ACCEPT_LANGUAGE_META = 'io.modelcontextprotocol/acceptLanguage'; + +/** + * The `_meta` key for the server's content language (response direction). + * Value is a BCP 47 language tag (or comma-separated list per RFC 9110 §8.5). + */ +export const CONTENT_LANGUAGE_META = 'io.modelcontextprotocol/contentLanguage'; + +/** + * Reads the `acceptLanguage` value from request `params._meta`. + */ +export function getAcceptLanguage(params: { _meta?: Record }): string | undefined { + return params?._meta?.[ACCEPT_LANGUAGE_META] as string | undefined; +} + +/** + * Sets the `acceptLanguage` value on request `params._meta`. + * Mutates the params object (creates `_meta` if absent). + */ +export function setAcceptLanguage(params: { _meta?: Record }, value: string): void { + if (!params._meta) { + params._meta = {}; + } + params._meta[ACCEPT_LANGUAGE_META] = value; +} + +/** + * Reads the `contentLanguage` value from a response result's `_meta`. + */ +export function getContentLanguage(result: { _meta?: Record }): string | undefined { + return result?._meta?.[CONTENT_LANGUAGE_META] as string | undefined; +} + +/** + * Sets the `contentLanguage` value on a response result's `_meta`. + * Mutates the result object (creates `_meta` if absent). + */ +export function setContentLanguage(result: { _meta?: Record }, value: string): void { + if (!result._meta) { + result._meta = {}; + } + result._meta[CONTENT_LANGUAGE_META] = value; +} + +/** + * Reads `contentLanguage` from a JSON-RPC error's `data._meta`. + * Per SEP-2792, localized error content uses `error.data._meta` since + * the JSON-RPC Error object has no top-level `_meta`. + */ +export function getErrorContentLanguage(errorData: unknown): string | undefined { + if (errorData && typeof errorData === 'object' && '_meta' in errorData) { + const meta = (errorData as { _meta?: Record })._meta; + if (meta && typeof meta[CONTENT_LANGUAGE_META] === 'string') { + return meta[CONTENT_LANGUAGE_META] as string; + } + } + return undefined; +} + +/** + * Sets `contentLanguage` on a JSON-RPC error data object's `_meta`. + * Mutates the data object (creates `_meta` if absent). + * If `data` is not an object, wraps it: `{ originalData, _meta: {...} }`. + * + * Returns the (possibly new) data object to assign back to `error.data`. + */ +export function setErrorContentLanguage(data: unknown, value: string): Record { + let obj: Record; + if (data && typeof data === 'object' && !Array.isArray(data)) { + obj = data as Record; + } else { + obj = data === undefined ? {} : { originalData: data }; + } + if (!obj._meta || typeof obj._meta !== 'object') { + obj._meta = {}; + } + (obj._meta as Record)[CONTENT_LANGUAGE_META] = value; + return obj; +} + +/** + * Parses an `Accept-Language` header value into an ordered list of locale tags. + * Strips quality values and sorts by descending quality. + */ +function parseAcceptLanguage(acceptLanguage: string): string[] { + const parsed = acceptLanguage + .split(',') + .map(part => { + const [tag, ...params] = part.trim().split(';'); + const qParam = params.find(p => p.trim().startsWith('q=')); + const q = qParam ? Number.parseFloat(qParam.trim().slice(2)) : 1; + return { tag: (tag ?? '').trim(), q }; + }) + .filter(({ tag }) => tag.length > 0 && tag !== '*'); + parsed.sort((a, b) => b.q - a.q); + return parsed.map(({ tag }) => tag); +} + +/** + * Negotiates the best language from `available` given an `Accept-Language` + * header value. Uses RFC 4647 "best fit" matching via `@formatjs/intl-localematcher`. + * + * @param acceptLanguage - An `Accept-Language` header value (e.g. `"fr-CA,fr;q=0.9,en;q=0.5"`) + * @param available - Array of BCP 47 tags the server supports (e.g. `["en", "fr", "de"]`) + * @param defaultLocale - Optional default locale if no match is found. If not provided, returns `undefined` on no match. + * @returns The best matching locale from `available`, or `defaultLocale`, or `undefined`. + */ +export function negotiateLanguage(acceptLanguage: string, available: string[], defaultLocale?: string): string | undefined { + if (!acceptLanguage || available.length === 0) { + return defaultLocale; + } + + const requested = parseAcceptLanguage(acceptLanguage); + if (requested.length === 0) { + return defaultLocale; + } + + try { + // @formatjs/intl-localematcher requires a defaultLocale; we use the first + // available as a sentinel and check if the result is meaningful. + const fallback = defaultLocale ?? available[0]!; + const result = match(requested, available, fallback); + // If no defaultLocale was provided and the result equals the sentinel, + // verify the match is genuine (the requested list actually wanted it). + if (!defaultLocale && result === available[0]) { + // Check if any requested locale actually matches the first available + const firstAvailable = available[0]!; + const genuineMatch = requested.some(r => { + const rLower = r.toLowerCase(); + const aLower = firstAvailable.toLowerCase(); + return aLower.startsWith(rLower) || rLower.startsWith(aLower) || rLower === aLower; + }); + if (!genuineMatch) { + return undefined; + } + } + return result; + } catch { + return defaultLocale; + } +} diff --git a/packages/core/test/shared/i18n.test.ts b/packages/core/test/shared/i18n.test.ts new file mode 100644 index 0000000000..641ecdeea7 --- /dev/null +++ b/packages/core/test/shared/i18n.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'vitest'; +import { + ACCEPT_LANGUAGE_META, + CONTENT_LANGUAGE_META, + getAcceptLanguage, + getContentLanguage, + getErrorContentLanguage, + negotiateLanguage, + setAcceptLanguage, + setContentLanguage, + setErrorContentLanguage +} from '../../src/shared/i18n.js'; + +describe('i18n helpers', () => { + describe('constants', () => { + it('has correct meta key names', () => { + expect(ACCEPT_LANGUAGE_META).toBe('io.modelcontextprotocol/acceptLanguage'); + expect(CONTENT_LANGUAGE_META).toBe('io.modelcontextprotocol/contentLanguage'); + }); + }); + + describe('getAcceptLanguage', () => { + it('returns undefined when _meta is absent', () => { + expect(getAcceptLanguage({})).toBeUndefined(); + }); + + it('returns undefined when key is absent', () => { + expect(getAcceptLanguage({ _meta: {} })).toBeUndefined(); + }); + + it('returns the value when present', () => { + const params = { _meta: { [ACCEPT_LANGUAGE_META]: 'fr-CA,en;q=0.5' } }; + expect(getAcceptLanguage(params)).toBe('fr-CA,en;q=0.5'); + }); + }); + + describe('setAcceptLanguage', () => { + it('creates _meta if absent', () => { + const params: { _meta?: Record } = {}; + setAcceptLanguage(params, 'de'); + expect(params._meta?.[ACCEPT_LANGUAGE_META]).toBe('de'); + }); + + it('sets value on existing _meta', () => { + const params: { _meta: Record } = { _meta: { other: 'value' } }; + setAcceptLanguage(params, 'en-US'); + expect(params._meta[ACCEPT_LANGUAGE_META]).toBe('en-US'); + expect(params._meta.other).toBe('value'); + }); + }); + + describe('getContentLanguage', () => { + it('returns undefined when _meta is absent', () => { + expect(getContentLanguage({})).toBeUndefined(); + }); + + it('returns the value when present', () => { + const result = { _meta: { [CONTENT_LANGUAGE_META]: 'fr' } }; + expect(getContentLanguage(result)).toBe('fr'); + }); + }); + + describe('setContentLanguage', () => { + it('creates _meta if absent', () => { + const result: { _meta?: Record } = {}; + setContentLanguage(result, 'de'); + expect(result._meta?.[CONTENT_LANGUAGE_META]).toBe('de'); + }); + + it('sets value on existing _meta', () => { + const result: { _meta: Record } = { _meta: { other: 'x' } }; + setContentLanguage(result, 'en'); + expect(result._meta[CONTENT_LANGUAGE_META]).toBe('en'); + }); + }); + + describe('negotiateLanguage', () => { + const available = ['en', 'fr', 'de']; + + it('returns exact match', () => { + expect(negotiateLanguage('fr', available)).toBe('fr'); + }); + + it('returns best match from quality-value list', () => { + expect(negotiateLanguage('fr-CA,fr;q=0.9,en;q=0.5', available)).toBe('fr'); + }); + + it('returns defaultLocale when no match', () => { + expect(negotiateLanguage('ja', available, 'en')).toBe('en'); + }); + + it('returns undefined when no match and no default', () => { + expect(negotiateLanguage('ja', available)).toBeUndefined(); + }); + + it('handles empty acceptLanguage', () => { + expect(negotiateLanguage('', available, 'en')).toBe('en'); + }); + + it('handles wildcard only', () => { + // Wildcard is filtered out, should fall back to default + expect(negotiateLanguage('*', available, 'en')).toBe('en'); + }); + + it('handles empty available list', () => { + expect(negotiateLanguage('en', [], 'en')).toBe('en'); + }); + + it('respects quality-value ordering', () => { + // de has highest quality + expect(negotiateLanguage('en;q=0.5,de;q=0.9,fr;q=0.7', available)).toBe('de'); + }); + + it('handles subtag matching (en-US matches en)', () => { + expect(negotiateLanguage('en-US', available)).toBe('en'); + }); + + it('handles multiple subtag matches preferring higher quality', () => { + expect(negotiateLanguage('de-AT;q=0.8,fr-CA;q=0.9', available)).toBe('fr'); + }); + }); + + describe('getErrorContentLanguage', () => { + it('returns undefined for null/undefined data', () => { + expect(getErrorContentLanguage(undefined)).toBeUndefined(); + expect(getErrorContentLanguage(null)).toBeUndefined(); + }); + + it('returns undefined when data has no _meta', () => { + expect(getErrorContentLanguage({ message: 'error' })).toBeUndefined(); + }); + + it('returns undefined when _meta has no contentLanguage', () => { + expect(getErrorContentLanguage({ _meta: { other: 'x' } })).toBeUndefined(); + }); + + it('returns the contentLanguage from data._meta', () => { + const data = { _meta: { [CONTENT_LANGUAGE_META]: 'fr' } }; + expect(getErrorContentLanguage(data)).toBe('fr'); + }); + + it('returns undefined for non-string contentLanguage', () => { + const data = { _meta: { [CONTENT_LANGUAGE_META]: 123 } }; + expect(getErrorContentLanguage(data)).toBeUndefined(); + }); + }); + + describe('setErrorContentLanguage', () => { + it('sets contentLanguage on an existing object', () => { + const data = { message: 'err' }; + const result = setErrorContentLanguage(data, 'de'); + expect(result._meta).toBeDefined(); + expect((result._meta as Record)[CONTENT_LANGUAGE_META]).toBe('de'); + expect(result.message).toBe('err'); + }); + + it('creates a wrapper object for non-object data', () => { + const result = setErrorContentLanguage('raw string', 'fr'); + expect(result.originalData).toBe('raw string'); + expect((result._meta as Record)[CONTENT_LANGUAGE_META]).toBe('fr'); + }); + + it('creates an empty object for undefined data', () => { + const result = setErrorContentLanguage(undefined, 'en'); + expect((result._meta as Record)[CONTENT_LANGUAGE_META]).toBe('en'); + expect(result.originalData).toBeUndefined(); + }); + + it('preserves existing _meta fields', () => { + const data = { _meta: { other: 'value' } }; + const result = setErrorContentLanguage(data, 'de'); + expect((result._meta as Record).other).toBe('value'); + expect((result._meta as Record)[CONTENT_LANGUAGE_META]).toBe('de'); + }); + }); +}); diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index fd3563a077..65170b8074 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -9,7 +9,9 @@ import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestId, Transport } from '@modelcontextprotocol/core'; import { + CONTENT_LANGUAGE_META, DEFAULT_NEGOTIATED_PROTOCOL_VERSION, + getErrorContentLanguage, isInitializeRequest, isJSONRPCErrorResponse, isJSONRPCRequest, @@ -897,6 +899,28 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { return undefined; } + /** + * SEP-2792: Extracts Content-Language value from response message(s) _meta. + * Checks both successful results (_meta) and error responses (error.data._meta). + * Returns the first contentLanguage value found, or undefined. + */ + private _extractContentLanguage(responses: JSONRPCMessage[]): string | undefined { + for (const msg of responses) { + if (isJSONRPCResultResponse(msg)) { + const meta = msg.result?._meta; + if (meta && typeof meta[CONTENT_LANGUAGE_META] === 'string') { + return meta[CONTENT_LANGUAGE_META] as string; + } + } else if (isJSONRPCErrorResponse(msg)) { + const lang = getErrorContentLanguage(msg.error?.data); + if (lang) { + return lang; + } + } + } + return undefined; + } + async close(): Promise { if (this._closed) { return; @@ -1018,6 +1042,12 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { const responses = relatedIds.map(id => this._requestResponseMap.get(id)!); + // SEP-2792: Mirror Content-Language from response _meta to HTTP header + const contentLang = this._extractContentLanguage(responses); + if (contentLang) { + headers['content-language'] = contentLang; + } + if (responses.length === 1) { stream.resolveJson(Response.json(responses[0], { status: 200, headers })); } else { diff --git a/packages/server/test/server/i18n.test.ts b/packages/server/test/server/i18n.test.ts new file mode 100644 index 0000000000..7bd8bd9906 --- /dev/null +++ b/packages/server/test/server/i18n.test.ts @@ -0,0 +1,366 @@ +/** + * Integration tests for SEP-2792: i18n per-request language negotiation. + * + * Tests HTTP transport header mirroring/mismatch and stdio per-request switching. + */ +import { randomUUID } from 'node:crypto'; + +import type { JSONRPCMessage } from '@modelcontextprotocol/core'; +import { ACCEPT_LANGUAGE_META, CONTENT_LANGUAGE_META, setErrorContentLanguage } from '@modelcontextprotocol/core'; + +import { ProtocolError } from '../../src/index.js'; +import { Server } from '../../src/server/server.js'; +import { WebStandardStreamableHTTPServerTransport } from '../../src/server/streamableHttp.js'; + +// ---------- helpers ---------- + +function createRequest( + method: string, + body?: JSONRPCMessage | JSONRPCMessage[], + options?: { + sessionId?: string; + accept?: string; + contentType?: string; + extraHeaders?: Record; + } +): Request { + const headers: Record = {}; + + if (options?.accept) { + headers['Accept'] = options.accept; + } else if (method === 'POST') { + headers['Accept'] = 'application/json, text/event-stream'; + } + + if (options?.contentType) { + headers['Content-Type'] = options.contentType; + } else if (body) { + headers['Content-Type'] = 'application/json'; + } + + if (options?.sessionId) { + headers['mcp-session-id'] = options.sessionId; + headers['mcp-protocol-version'] = '2025-11-25'; + } + + if (options?.extraHeaders) { + Object.assign(headers, options.extraHeaders); + } + + return new Request('http://localhost/mcp', { + method, + headers, + body: body ? JSON.stringify(body) : undefined + }); +} + +function createI18nServer(): { server: Server; transport: WebStandardStreamableHTTPServerTransport } { + const server = new Server({ name: 'i18n-test-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + + server.setRequestHandler('tools/list', (_request, ctx) => { + const lang = (ctx.mcpReq._meta?.[ACCEPT_LANGUAGE_META] as string) ?? 'en'; + const titles: Record = { en: 'Greet', fr: 'Saluer', de: 'Grüßen' }; + const resolved = lang.startsWith('fr') ? 'fr' : lang.startsWith('de') ? 'de' : 'en'; + + return { + tools: [{ name: 'greet', title: titles[resolved] ?? 'Greet', inputSchema: { type: 'object' as const } }], + _meta: { [CONTENT_LANGUAGE_META]: resolved } + }; + }); + + server.setRequestHandler('tools/call', (request, ctx) => { + const lang = (ctx.mcpReq._meta?.[ACCEPT_LANGUAGE_META] as string) ?? 'en'; + const name = (request.params as { arguments?: { name?: string } }).arguments?.name ?? 'World'; + const greetings: Record = { en: `Hello, ${name}!`, fr: `Bonjour, ${name}!`, de: `Hallo, ${name}!` }; + const resolved = lang.startsWith('fr') ? 'fr' : lang.startsWith('de') ? 'de' : 'en'; + return { + content: [{ type: 'text' as const, text: greetings[resolved] ?? greetings.en! }], + _meta: { [CONTENT_LANGUAGE_META]: resolved } + } as never; + }); + + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + enableJsonResponse: true + }); + + return { server, transport }; +} + +// ---------- HTTP transport integration tests ---------- + +describe('SEP-2792 i18n HTTP transport integration', () => { + let transport: WebStandardStreamableHTTPServerTransport; + let server: Server; + let sessionId: string; + + beforeEach(async () => { + ({ server, transport } = createI18nServer()); + await server.connect(transport); + + // Initialize + const initReq = createRequest('POST', { + jsonrpc: '2.0', + method: 'initialize', + params: { clientInfo: { name: 'test', version: '1.0' }, protocolVersion: '2025-11-25', capabilities: {} }, + id: 'init-1' + } as JSONRPCMessage); + const initResp = await transport.handleRequest(initReq); + expect(initResp.status).toBe(200); + sessionId = initResp.headers.get('mcp-session-id')!; + + // Send initialized notification + const notifReq = createRequest( + 'POST', + { + jsonrpc: '2.0', + method: 'notifications/initialized', + params: {} + } as JSONRPCMessage, + { sessionId } + ); + await transport.handleRequest(notifReq); + }); + + afterEach(async () => { + await transport.close(); + }); + + it('ignores bare Accept-Language header when _meta is absent (no fallback)', async () => { + // Per SEP-2792: bare header without _meta is NOT treated as language preference + const req = createRequest('POST', { jsonrpc: '2.0', method: 'tools/list', params: {}, id: 'tl-1' } as JSONRPCMessage, { + sessionId, + extraHeaders: { 'Accept-Language': 'fr' } + }); + + const resp = await transport.handleRequest(req); + expect(resp.status).toBe(200); + + const body = (await resp.json()) as { result?: { tools?: Array<{ title?: string }>; _meta?: Record } }; + // Server returns default language (en), not the header value (fr) + expect(body.result?.tools?.[0]?.title).toBe('Greet'); + expect(body.result?._meta?.[CONTENT_LANGUAGE_META]).toBe('en'); + }); + + it('sets Content-Language header on JSON response', async () => { + const req = createRequest( + 'POST', + { jsonrpc: '2.0', method: 'tools/list', params: { _meta: { [ACCEPT_LANGUAGE_META]: 'de' } }, id: 'tl-2' } as JSONRPCMessage, + { sessionId, extraHeaders: { 'Accept-Language': 'de' } } + ); + + const resp = await transport.handleRequest(req); + expect(resp.status).toBe(200); + expect(resp.headers.get('content-language')).toBe('de'); + }); + + it('succeeds when header disagrees with _meta (intermediary stripped/rewrote header)', async () => { + // Per SEP-2792: _meta is canonical; header mismatch is not an error. + // This covers the scenario where a CDN/proxy strips or rewrites Accept-Language. + const req = createRequest( + 'POST', + { + jsonrpc: '2.0', + method: 'tools/list', + params: { _meta: { [ACCEPT_LANGUAGE_META]: 'fr' } }, + id: 'tl-3' + } as JSONRPCMessage, + { sessionId, extraHeaders: { 'Accept-Language': 'de' } } + ); + + const resp = await transport.handleRequest(req); + expect(resp.status).toBe(200); + // Server honors _meta value (fr), not the header (de) + const body = (await resp.json()) as { result?: { tools?: Array<{ title?: string }>; _meta?: Record } }; + expect(body.result?.tools?.[0]?.title).toBe('Saluer'); + expect(body.result?._meta?.[CONTENT_LANGUAGE_META]).toBe('fr'); + }); + + it('succeeds when header is absent but _meta is present (header stripped by intermediary)', async () => { + // No Accept-Language header at all, but _meta carries the preference + const req = createRequest( + 'POST', + { + jsonrpc: '2.0', + method: 'tools/list', + params: { _meta: { [ACCEPT_LANGUAGE_META]: 'de' } }, + id: 'tl-5' + } as JSONRPCMessage, + { sessionId } + ); + + const resp = await transport.handleRequest(req); + expect(resp.status).toBe(200); + const body = (await resp.json()) as { result?: { tools?: Array<{ title?: string }>; _meta?: Record } }; + expect(body.result?.tools?.[0]?.title).toBe('Grüßen'); + expect(body.result?._meta?.[CONTENT_LANGUAGE_META]).toBe('de'); + }); + + it('passes through when header and _meta agree', async () => { + const req = createRequest( + 'POST', + { + jsonrpc: '2.0', + method: 'tools/list', + params: { _meta: { [ACCEPT_LANGUAGE_META]: 'fr' } }, + id: 'tl-4' + } as JSONRPCMessage, + { sessionId, extraHeaders: { 'Accept-Language': 'fr' } } + ); + + const resp = await transport.handleRequest(req); + expect(resp.status).toBe(200); + const body = (await resp.json()) as { result?: { tools?: Array<{ title?: string }> } }; + expect(body.result?.tools?.[0]?.title).toBe('Saluer'); + }); + + it('mirrors Content-Language header from error response data._meta', async () => { + // Create a server that throws a localized error + const errorServer = new Server({ name: 'i18n-error-test', version: '1.0.0' }, { capabilities: { tools: {} } }); + errorServer.setRequestHandler('tools/call', (_request, ctx) => { + const lang = (ctx.mcpReq._meta?.[ACCEPT_LANGUAGE_META] as string) ?? 'en'; + const resolved = lang.startsWith('fr') ? 'fr' : 'en'; + const errorData = setErrorContentLanguage({}, resolved); + throw new ProtocolError(-32_602, `Localized error in ${resolved}`, errorData); + }); + + const errorTransport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + enableJsonResponse: true + }); + await errorServer.connect(errorTransport); + + // Initialize + const initReq = createRequest('POST', { + jsonrpc: '2.0', + method: 'initialize', + params: { clientInfo: { name: 'test', version: '1.0' }, protocolVersion: '2025-11-25', capabilities: {} }, + id: 'init-err' + } as JSONRPCMessage); + const initResp = await errorTransport.handleRequest(initReq); + const errSessionId = initResp.headers.get('mcp-session-id')!; + + await errorTransport.handleRequest( + createRequest('POST', { jsonrpc: '2.0', method: 'notifications/initialized', params: {} } as JSONRPCMessage, { + sessionId: errSessionId + }) + ); + + // Call tool that throws localized error + const req = createRequest( + 'POST', + { + jsonrpc: '2.0', + method: 'tools/call', + params: { name: 'greet', arguments: {}, _meta: { [ACCEPT_LANGUAGE_META]: 'fr' } }, + id: 'err-1' + } as JSONRPCMessage, + { sessionId: errSessionId, extraHeaders: { 'Accept-Language': 'fr' } } + ); + + const resp = await errorTransport.handleRequest(req); + expect(resp.status).toBe(200); + expect(resp.headers.get('content-language')).toBe('fr'); + + const body = (await resp.json()) as { error?: { code?: number; data?: unknown } }; + expect(body.error?.code).toBe(-32_602); + + await errorTransport.close(); + }); +}); + +// ---------- stdio transport integration test ---------- + +describe('SEP-2792 i18n stdio transport integration', () => { + it('supports mid-session language switching via _meta', async () => { + // Use InMemoryTransport to simulate stdio-like transport (no HTTP headers) + const server = new Server({ name: 'i18n-test-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + + server.setRequestHandler('tools/list', (_request, ctx) => { + const lang = (ctx.mcpReq._meta?.[ACCEPT_LANGUAGE_META] as string) ?? 'en'; + const titles: Record = { en: 'Greet', fr: 'Saluer', de: 'Grüßen' }; + const resolved = lang.startsWith('fr') ? 'fr' : lang.startsWith('de') ? 'de' : 'en'; + return { + tools: [{ name: 'greet', title: titles[resolved] ?? 'Greet', inputSchema: { type: 'object' as const } }], + _meta: { [CONTENT_LANGUAGE_META]: resolved } + }; + }); + + const { InMemoryTransport } = await import('@modelcontextprotocol/core'); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await server.connect(serverTransport); + + // Simulate client initialization + const initMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'initialize', + params: { clientInfo: { name: 'test', version: '1.0' }, protocolVersion: '2025-11-25', capabilities: {} }, + id: 'init-1' + }; + + const responses: JSONRPCMessage[] = []; + clientTransport.onmessage = msg => { + responses.push(msg as JSONRPCMessage); + }; + await clientTransport.start(); + await clientTransport.send(initMessage); + + // Wait for init response + await new Promise(resolve => setTimeout(resolve, 100)); + + // Send initialized notification + await clientTransport.send({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} } as JSONRPCMessage); + await new Promise(resolve => setTimeout(resolve, 50)); + + responses.length = 0; + + // Request tools/list in English + await clientTransport.send({ + jsonrpc: '2.0', + method: 'tools/list', + params: { _meta: { [ACCEPT_LANGUAGE_META]: 'en' } }, + id: 'en-1' + } as JSONRPCMessage); + await new Promise(resolve => setTimeout(resolve, 100)); + + const enResp = responses.find(r => 'id' in r && r.id === 'en-1') as + | { result?: { tools?: Array<{ title?: string }>; _meta?: Record } } + | undefined; + expect(enResp?.result?.tools?.[0]?.title).toBe('Greet'); + expect(enResp?.result?._meta?.[CONTENT_LANGUAGE_META]).toBe('en'); + + // Request tools/list in French on the SAME connection + await clientTransport.send({ + jsonrpc: '2.0', + method: 'tools/list', + params: { _meta: { [ACCEPT_LANGUAGE_META]: 'fr' } }, + id: 'fr-1' + } as JSONRPCMessage); + await new Promise(resolve => setTimeout(resolve, 100)); + + const frResp = responses.find(r => 'id' in r && r.id === 'fr-1') as + | { result?: { tools?: Array<{ title?: string }>; _meta?: Record } } + | undefined; + expect(frResp?.result?.tools?.[0]?.title).toBe('Saluer'); + expect(frResp?.result?._meta?.[CONTENT_LANGUAGE_META]).toBe('fr'); + + // Request tools/list in German on the SAME connection + await clientTransport.send({ + jsonrpc: '2.0', + method: 'tools/list', + params: { _meta: { [ACCEPT_LANGUAGE_META]: 'de' } }, + id: 'de-1' + } as JSONRPCMessage); + await new Promise(resolve => setTimeout(resolve, 100)); + + const deResp = responses.find(r => 'id' in r && r.id === 'de-1') as + | { result?: { tools?: Array<{ title?: string }>; _meta?: Record } } + | undefined; + expect(deResp?.result?.tools?.[0]?.title).toBe('Grüßen'); + expect(deResp?.result?._meta?.[CONTENT_LANGUAGE_META]).toBe('de'); + + await clientTransport.close(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4fc799822..475ca2aed5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -251,7 +251,7 @@ importers: version: 4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.4) eslint-plugin-import: specifier: ^2.32.0 - version: 2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) + version: 2.32.0(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) eslint-plugin-n: specifier: catalog:devTools version: 17.24.0(eslint@9.39.4)(typescript@5.9.3) @@ -626,6 +626,9 @@ importers: packages/core: dependencies: + '@formatjs/intl-localematcher': + specifier: ^0.8.8 + version: 0.8.8 ajv: specifier: catalog:runtimeShared version: 8.18.0 @@ -1712,6 +1715,12 @@ packages: '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + '@formatjs/fast-memoize@3.1.5': + resolution: {integrity: sha512-KLi3fan6WnCHmigd9pmEEN8Hid0v4wiFBW576M/d07KMWYecf1CvyMI3n34vCmHT4AoVqG2n702kiHbXjzZX2A==} + + '@formatjs/intl-localematcher@0.8.8': + resolution: {integrity: sha512-pBr2hVKWvkHVnfXegW+53NT9U2uaVQCc+EgzLPCCwXqBA3nvM5fPbK9IcJlNjV+NMKGyZ2F3ZSG78iGdxAAqbA==} + '@gerrit0/mini-shiki@3.23.0': resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==} @@ -5649,6 +5658,12 @@ snapshots: '@fastify/forwarded': 3.0.1 ipaddr.js: 2.3.0 + '@formatjs/fast-memoize@3.1.5': {} + + '@formatjs/intl-localematcher@0.8.8': + dependencies: + '@formatjs/fast-memoize': 3.1.5 + '@gerrit0/mini-shiki@3.23.0': dependencies: '@shikijs/engine-oniguruma': 3.23.0 @@ -7161,15 +7176,14 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) + eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4): + eslint-module-utils@2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.57.2(eslint@9.39.4)(typescript@5.9.3) eslint: 9.39.4 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.4) @@ -7183,7 +7197,7 @@ snapshots: eslint: 9.39.4 eslint-compat-utils: 0.5.1(eslint@9.39.4) - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4): + eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -7194,7 +7208,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) + eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -7205,8 +7219,6 @@ snapshots: semver: 6.3.1 string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 8.57.2(eslint@9.39.4)(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack