From cbd2d609a95a44a730d904a5e4537cb57b0e749d Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Tue, 26 May 2026 23:00:33 +0200 Subject: [PATCH 1/4] feat: SEP-2792 reference implementation for per-request language negotiation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- examples/client/src/i18nClient.ts | 70 +++++ examples/server/src/i18nExample.ts | 166 +++++++++++ packages/client/src/client/streamableHttp.ts | 36 +++ packages/core/package.json | 7 +- packages/core/src/exports/public/index.ts | 11 + packages/core/src/index.ts | 1 + packages/core/src/shared/i18n.ts | 121 ++++++++ packages/core/test/shared/i18n.test.ts | 120 ++++++++ packages/server/src/server/streamableHttp.ts | 66 +++++ packages/server/test/server/i18n.test.ts | 285 +++++++++++++++++++ pnpm-lock.yaml | 28 +- 11 files changed, 900 insertions(+), 11 deletions(-) create mode 100644 examples/client/src/i18nClient.ts create mode 100644 examples/server/src/i18nExample.ts create mode 100644 packages/core/src/shared/i18n.ts create mode 100644 packages/core/test/shared/i18n.test.ts create mode 100644 packages/server/test/server/i18n.test.ts diff --git a/examples/client/src/i18nClient.ts b/examples/client/src/i18nClient.ts new file mode 100644 index 0000000000..d3fbb39e81 --- /dev/null +++ b/examples/client/src/i18nClient.ts @@ -0,0 +1,70 @@ +/** + * 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, 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}"`); + 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..b107743517 --- /dev/null +++ b/examples/server/src/i18nExample.ts @@ -0,0 +1,166 @@ +/** + * 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, setContentLanguage } 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.' + } +}; + +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')!; + + 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..85331a6091 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,28 @@ export class StreamableHTTPClientTransport implements Transport { }); } + /** + * Extracts the acceptLanguage value from message(s) _meta for header mirroring (SEP-2792). + * For batched messages with differing values, returns the union of language ranges. + */ + private _extractAcceptLanguage(message: JSONRPCMessage | JSONRPCMessage[]): string | undefined { + const messages = Array.isArray(message) ? message : [message]; + const values: string[] = []; + for (const msg of messages) { + 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') { + values.push(meta[ACCEPT_LANGUAGE_META] as string); + } + } + } + if (values.length === 0) return undefined; + // For batched messages with different values, union the language ranges + if (values.length === 1) return values[0]; + const unique = [...new Set(values)]; + return unique.length === 1 ? unique[0] : unique.join(', '); + } + private async _startOrAuthSse(options: StartSSEOptions, isAuthRetry = false): Promise { const { resumptionToken } = options; @@ -546,6 +569,19 @@ 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: Mirror acceptLanguage from _meta to Accept-Language header + const metaAcceptLanguage = this._extractAcceptLanguage(message); + if (metaAcceptLanguage) { + const existingHeader = headers.get('accept-language'); + if (existingHeader && existingHeader !== metaAcceptLanguage) { + throw new SdkError( + SdkErrorCode.SendFailed, + `Accept-Language header "${existingHeader}" conflicts with _meta["${ACCEPT_LANGUAGE_META}"] value "${metaAcceptLanguage}". They must be identical per SEP-2792.` + ); + } + 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..3739742433 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -74,6 +74,17 @@ 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, + negotiateLanguage, + setAcceptLanguage, + setContentLanguage +} 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..1392f86768 --- /dev/null +++ b/packages/core/src/shared/i18n.ts @@ -0,0 +1,121 @@ +/** + * 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; +} + +/** + * 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..785b501343 --- /dev/null +++ b/packages/core/test/shared/i18n.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest'; +import { + ACCEPT_LANGUAGE_META, + CONTENT_LANGUAGE_META, + getAcceptLanguage, + getContentLanguage, + negotiateLanguage, + setAcceptLanguage, + setContentLanguage +} 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'); + }); + }); +}); diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index fd3563a077..7b8dcc376e 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -9,6 +9,8 @@ import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestId, Transport } from '@modelcontextprotocol/core'; import { + ACCEPT_LANGUAGE_META, + CONTENT_LANGUAGE_META, DEFAULT_NEGOTIATED_PROTOCOL_VERSION, isInitializeRequest, isJSONRPCErrorResponse, @@ -699,6 +701,12 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { } } + // SEP-2792: Validate Accept-Language header vs _meta and copy header → _meta if needed + const langError = this.validateAndCopyAcceptLanguage(req, messages); + if (langError) { + return langError; + } + // check if it contains requests const hasRequests = messages.some(element => isJSONRPCRequest(element)); @@ -897,6 +905,58 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { return undefined; } + /** + * SEP-2792: Validates Accept-Language header against _meta fields. + * - If header and _meta both present and disagree → HTTP 400 + * - If header present but _meta absent → copies header value into _meta + */ + private validateAndCopyAcceptLanguage(req: Request, messages: JSONRPCMessage[]): Response | undefined { + const headerValue = req.headers.get('accept-language'); + if (!headerValue) { + return undefined; + } + + for (const msg of messages) { + if (!('params' in msg) || !msg.params || typeof msg.params !== 'object') { + continue; + } + const params = msg.params as { _meta?: Record }; + const metaValue = params._meta?.[ACCEPT_LANGUAGE_META]; + + if (metaValue !== undefined && typeof metaValue === 'string') { + // Both present: check for mismatch + if (metaValue !== headerValue) { + const error = `Bad Request: Accept-Language header "${headerValue}" does not match _meta["${ACCEPT_LANGUAGE_META}"] value "${metaValue}"`; + this.onerror?.(new Error(error)); + return this.createJsonErrorResponse(400, -32_000, error); + } + } else { + // Header present, _meta absent: copy header → _meta + if (!params._meta) { + params._meta = {}; + } + params._meta[ACCEPT_LANGUAGE_META] = headerValue; + } + } + return undefined; + } + + /** + * SEP-2792: Extracts Content-Language value from response message(s) _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; + } + } + } + return undefined; + } + async close(): Promise { if (this._closed) { return; @@ -1018,6 +1078,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..b1082aebb5 --- /dev/null +++ b/packages/server/test/server/i18n.test.ts @@ -0,0 +1,285 @@ +/** + * 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 } from '@modelcontextprotocol/core'; + +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('mirrors Accept-Language header into _meta when only header is present', async () => { + 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 } }; + expect(body.result?.tools?.[0]?.title).toBe('Saluer'); + expect(body.result?._meta?.[CONTENT_LANGUAGE_META]).toBe('fr'); + }); + + 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('returns 400 on header/_meta mismatch', async () => { + 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(400); + const body = (await resp.json()) as { error?: { message?: string } }; + expect(body.error?.message).toMatch(/does not match/); + }); + + 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'); + }); +}); + +// ---------- 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 From 5a5f548ffa1e809c8aabc0db8403f15ebffeacf1 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 27 May 2026 15:36:20 +0200 Subject: [PATCH 2/4] fix: address SEP-2792 review feedback 1. Use -32001 HeaderMismatch error code (SEP-2243) for language header/body mismatch on both client and server sides. 2. Add error-response localization: getErrorContentLanguage/ setErrorContentLanguage helpers for error.data._meta, mirror Content-Language header from error responses, demonstrate in example server (empty name triggers localized error). 3. Remove batch handling from client Accept-Language extraction (MCP no longer permits JSON-RPC batches over Streamable HTTP). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- examples/client/src/i18nClient.ts | 22 ++++++- examples/server/src/i18nExample.ts | 22 ++++++- packages/client/src/client/streamableHttp.ts | 26 +++----- packages/core/src/exports/public/index.ts | 5 +- packages/core/src/shared/i18n.ts | 42 ++++++++++++ packages/core/test/shared/i18n.test.ts | 65 +++++++++++++++++- packages/server/src/server/streamableHttp.ts | 12 +++- packages/server/test/server/i18n.test.ts | 69 ++++++++++++++++++-- 8 files changed, 237 insertions(+), 26 deletions(-) diff --git a/examples/client/src/i18nClient.ts b/examples/client/src/i18nClient.ts index d3fbb39e81..86557e4c9a 100644 --- a/examples/client/src/i18nClient.ts +++ b/examples/client/src/i18nClient.ts @@ -11,7 +11,13 @@ * Run with stdio: tsx src/i18nClient.ts stdio */ -import { ACCEPT_LANGUAGE_META, Client, CONTENT_LANGUAGE_META, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +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']; @@ -48,6 +54,20 @@ async function runWithTransport( 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(''); } diff --git a/examples/server/src/i18nExample.ts b/examples/server/src/i18nExample.ts index b107743517..2a39e884ec 100644 --- a/examples/server/src/i18nExample.ts +++ b/examples/server/src/i18nExample.ts @@ -12,7 +12,15 @@ import { createMcpExpressApp } from '@modelcontextprotocol/express'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import type { CallToolResult, ListToolsResult } from '@modelcontextprotocol/server'; -import { ACCEPT_LANGUAGE_META, getAcceptLanguage, McpServer, negotiateLanguage, setContentLanguage } 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'; @@ -35,6 +43,11 @@ const STRINGS: Record> = { 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.' } }; @@ -97,6 +110,13 @@ function createI18nServer(): McpServer { 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: [ { diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 85331a6091..df8261ac88 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -4,6 +4,7 @@ import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol import { ACCEPT_LANGUAGE_META, createFetchWithInit, + HEADER_MISMATCH_ERROR_CODE, isInitializedNotification, isJSONRPCErrorResponse, isJSONRPCRequest, @@ -233,25 +234,18 @@ export class StreamableHTTPClientTransport implements Transport { } /** - * Extracts the acceptLanguage value from message(s) _meta for header mirroring (SEP-2792). - * For batched messages with differing values, returns the union of language ranges. + * Extracts the acceptLanguage value from a message's _meta for header mirroring (SEP-2792). */ private _extractAcceptLanguage(message: JSONRPCMessage | JSONRPCMessage[]): string | undefined { - const messages = Array.isArray(message) ? message : [message]; - const values: string[] = []; - for (const msg of messages) { - 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') { - values.push(meta[ACCEPT_LANGUAGE_META] as string); - } + 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; } } - if (values.length === 0) return undefined; - // For batched messages with different values, union the language ranges - if (values.length === 1) return values[0]; - const unique = [...new Set(values)]; - return unique.length === 1 ? unique[0] : unique.join(', '); + return undefined; } private async _startOrAuthSse(options: StartSSEOptions, isAuthRetry = false): Promise { @@ -576,7 +570,7 @@ export class StreamableHTTPClientTransport implements Transport { if (existingHeader && existingHeader !== metaAcceptLanguage) { throw new SdkError( SdkErrorCode.SendFailed, - `Accept-Language header "${existingHeader}" conflicts with _meta["${ACCEPT_LANGUAGE_META}"] value "${metaAcceptLanguage}". They must be identical per SEP-2792.` + `Accept-Language header "${existingHeader}" conflicts with _meta["${ACCEPT_LANGUAGE_META}"] value "${metaAcceptLanguage}". They must be identical per SEP-2243 (HeaderMismatch code ${HEADER_MISMATCH_ERROR_CODE}).` ); } headers.set('accept-language', metaAcceptLanguage); diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index 3739742433..6cec7d766b 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -80,9 +80,12 @@ export { CONTENT_LANGUAGE_META, getAcceptLanguage, getContentLanguage, + getErrorContentLanguage, + HEADER_MISMATCH_ERROR_CODE, negotiateLanguage, setAcceptLanguage, - setContentLanguage + setContentLanguage, + setErrorContentLanguage } from '../../shared/i18n.js'; // URI Template diff --git a/packages/core/src/shared/i18n.ts b/packages/core/src/shared/i18n.ts index 1392f86768..363f690acb 100644 --- a/packages/core/src/shared/i18n.ts +++ b/packages/core/src/shared/i18n.ts @@ -10,6 +10,12 @@ import { match } from '@formatjs/intl-localematcher'; +/** + * JSON-RPC error code for header/body mismatch (SEP-2243). + * Used when an HTTP header value disagrees with the corresponding `_meta` field. + */ +export const HEADER_MISMATCH_ERROR_CODE = -32_001; + /** * The `_meta` key for the client's language preference (request direction). * Value syntax matches the HTTP `Accept-Language` field (RFC 9110 §12.5.4). @@ -58,6 +64,42 @@ export function setContentLanguage(result: { _meta?: Record }, 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. diff --git a/packages/core/test/shared/i18n.test.ts b/packages/core/test/shared/i18n.test.ts index 785b501343..21d133d471 100644 --- a/packages/core/test/shared/i18n.test.ts +++ b/packages/core/test/shared/i18n.test.ts @@ -4,9 +4,12 @@ import { CONTENT_LANGUAGE_META, getAcceptLanguage, getContentLanguage, + getErrorContentLanguage, + HEADER_MISMATCH_ERROR_CODE, negotiateLanguage, setAcceptLanguage, - setContentLanguage + setContentLanguage, + setErrorContentLanguage } from '../../src/shared/i18n.js'; describe('i18n helpers', () => { @@ -117,4 +120,64 @@ describe('i18n helpers', () => { expect(negotiateLanguage('de-AT;q=0.8,fr-CA;q=0.9', available)).toBe('fr'); }); }); + + describe('HEADER_MISMATCH_ERROR_CODE', () => { + it('equals -32001', () => { + expect(HEADER_MISMATCH_ERROR_CODE).toBe(-32_001); + }); + }); + + 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 7b8dcc376e..bf284ffbb5 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -12,6 +12,8 @@ import { ACCEPT_LANGUAGE_META, CONTENT_LANGUAGE_META, DEFAULT_NEGOTIATED_PROTOCOL_VERSION, + getErrorContentLanguage, + HEADER_MISMATCH_ERROR_CODE, isInitializeRequest, isJSONRPCErrorResponse, isJSONRPCRequest, @@ -924,11 +926,11 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { const metaValue = params._meta?.[ACCEPT_LANGUAGE_META]; if (metaValue !== undefined && typeof metaValue === 'string') { - // Both present: check for mismatch + // Both present: check for mismatch (SEP-2243 HeaderMismatch) if (metaValue !== headerValue) { const error = `Bad Request: Accept-Language header "${headerValue}" does not match _meta["${ACCEPT_LANGUAGE_META}"] value "${metaValue}"`; this.onerror?.(new Error(error)); - return this.createJsonErrorResponse(400, -32_000, error); + return this.createJsonErrorResponse(400, HEADER_MISMATCH_ERROR_CODE, error); } } else { // Header present, _meta absent: copy header → _meta @@ -943,6 +945,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { /** * 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 { @@ -952,6 +955,11 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { 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; diff --git a/packages/server/test/server/i18n.test.ts b/packages/server/test/server/i18n.test.ts index b1082aebb5..5ea2d65e6d 100644 --- a/packages/server/test/server/i18n.test.ts +++ b/packages/server/test/server/i18n.test.ts @@ -6,8 +6,14 @@ import { randomUUID } from 'node:crypto'; import type { JSONRPCMessage } from '@modelcontextprotocol/core'; -import { ACCEPT_LANGUAGE_META, CONTENT_LANGUAGE_META } from '@modelcontextprotocol/core'; - +import { + ACCEPT_LANGUAGE_META, + CONTENT_LANGUAGE_META, + HEADER_MISMATCH_ERROR_CODE, + setErrorContentLanguage +} from '@modelcontextprotocol/core'; + +import { ProtocolError } from '../../src/index.js'; import { Server } from '../../src/server/server.js'; import { WebStandardStreamableHTTPServerTransport } from '../../src/server/streamableHttp.js'; @@ -151,7 +157,7 @@ describe('SEP-2792 i18n HTTP transport integration', () => { expect(resp.headers.get('content-language')).toBe('de'); }); - it('returns 400 on header/_meta mismatch', async () => { + it('returns 400 with HeaderMismatch error code on header/_meta mismatch', async () => { const req = createRequest( 'POST', { @@ -165,7 +171,8 @@ describe('SEP-2792 i18n HTTP transport integration', () => { const resp = await transport.handleRequest(req); expect(resp.status).toBe(400); - const body = (await resp.json()) as { error?: { message?: string } }; + const body = (await resp.json()) as { error?: { code?: number; message?: string } }; + expect(body.error?.code).toBe(HEADER_MISMATCH_ERROR_CODE); expect(body.error?.message).toMatch(/does not match/); }); @@ -186,6 +193,60 @@ describe('SEP-2792 i18n HTTP transport integration', () => { 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 ---------- From cdb62fbccccf0ff7a5d10cd0a0db715ed2f023f3 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Fri, 29 May 2026 16:16:01 +0200 Subject: [PATCH 3/4] refactor(i18n): remove header/body mismatch rejection per SEP-2792 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SEP-2792 strict mismatch rule is dropped per reviewer feedback: intermediaries (CloudFront, Fastly, Varnish) routinely strip or rewrite Accept-Language, making byte-equality enforcement unreliable. Changes: - Server: rename validateAndCopyAcceptLanguage → copyAcceptLanguageFromHeader; always use _meta as canonical, fall back to header only when _meta absent - Client: remove mismatch throw; always mirror _meta → header unconditionally - Delete HEADER_MISMATCH_ERROR_CODE constant (no longer used) - Replace mismatch integration test with positive 'stripped header' tests - Remove HEADER_MISMATCH_ERROR_CODE unit test Refs: SEP commit 63dc3e1a on SamMorrowDrums/modelcontextprotocol Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/client/src/client/streamableHttp.ts | 11 +----- packages/core/src/exports/public/index.ts | 1 - packages/core/src/shared/i18n.ts | 6 --- packages/core/test/shared/i18n.test.ts | 7 ---- packages/server/src/server/streamableHttp.ts | 31 +++++---------- packages/server/test/server/i18n.test.ts | 40 ++++++++++++++------ 6 files changed, 41 insertions(+), 55 deletions(-) diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index df8261ac88..61b916d908 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -4,7 +4,6 @@ import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol import { ACCEPT_LANGUAGE_META, createFetchWithInit, - HEADER_MISMATCH_ERROR_CODE, isInitializedNotification, isJSONRPCErrorResponse, isJSONRPCRequest, @@ -563,16 +562,10 @@ 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: Mirror acceptLanguage from _meta to Accept-Language header + // 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) { - const existingHeader = headers.get('accept-language'); - if (existingHeader && existingHeader !== metaAcceptLanguage) { - throw new SdkError( - SdkErrorCode.SendFailed, - `Accept-Language header "${existingHeader}" conflicts with _meta["${ACCEPT_LANGUAGE_META}"] value "${metaAcceptLanguage}". They must be identical per SEP-2243 (HeaderMismatch code ${HEADER_MISMATCH_ERROR_CODE}).` - ); - } headers.set('accept-language', metaAcceptLanguage); } diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index 6cec7d766b..a12022e139 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -81,7 +81,6 @@ export { getAcceptLanguage, getContentLanguage, getErrorContentLanguage, - HEADER_MISMATCH_ERROR_CODE, negotiateLanguage, setAcceptLanguage, setContentLanguage, diff --git a/packages/core/src/shared/i18n.ts b/packages/core/src/shared/i18n.ts index 363f690acb..cd2a48dc37 100644 --- a/packages/core/src/shared/i18n.ts +++ b/packages/core/src/shared/i18n.ts @@ -10,12 +10,6 @@ import { match } from '@formatjs/intl-localematcher'; -/** - * JSON-RPC error code for header/body mismatch (SEP-2243). - * Used when an HTTP header value disagrees with the corresponding `_meta` field. - */ -export const HEADER_MISMATCH_ERROR_CODE = -32_001; - /** * The `_meta` key for the client's language preference (request direction). * Value syntax matches the HTTP `Accept-Language` field (RFC 9110 §12.5.4). diff --git a/packages/core/test/shared/i18n.test.ts b/packages/core/test/shared/i18n.test.ts index 21d133d471..641ecdeea7 100644 --- a/packages/core/test/shared/i18n.test.ts +++ b/packages/core/test/shared/i18n.test.ts @@ -5,7 +5,6 @@ import { getAcceptLanguage, getContentLanguage, getErrorContentLanguage, - HEADER_MISMATCH_ERROR_CODE, negotiateLanguage, setAcceptLanguage, setContentLanguage, @@ -121,12 +120,6 @@ describe('i18n helpers', () => { }); }); - describe('HEADER_MISMATCH_ERROR_CODE', () => { - it('equals -32001', () => { - expect(HEADER_MISMATCH_ERROR_CODE).toBe(-32_001); - }); - }); - describe('getErrorContentLanguage', () => { it('returns undefined for null/undefined data', () => { expect(getErrorContentLanguage(undefined)).toBeUndefined(); diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index bf284ffbb5..f1885853f9 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -13,7 +13,6 @@ import { CONTENT_LANGUAGE_META, DEFAULT_NEGOTIATED_PROTOCOL_VERSION, getErrorContentLanguage, - HEADER_MISMATCH_ERROR_CODE, isInitializeRequest, isJSONRPCErrorResponse, isJSONRPCRequest, @@ -703,11 +702,8 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { } } - // SEP-2792: Validate Accept-Language header vs _meta and copy header → _meta if needed - const langError = this.validateAndCopyAcceptLanguage(req, messages); - if (langError) { - return langError; - } + // SEP-2792: Copy Accept-Language header → _meta if _meta is absent + this.copyAcceptLanguageFromHeader(req, messages); // check if it contains requests const hasRequests = messages.some(element => isJSONRPCRequest(element)); @@ -908,14 +904,14 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { } /** - * SEP-2792: Validates Accept-Language header against _meta fields. - * - If header and _meta both present and disagree → HTTP 400 - * - If header present but _meta absent → copies header value into _meta + * SEP-2792: Copies Accept-Language header into _meta when _meta is absent. + * If _meta[acceptLanguage] is already set, it takes precedence (the header is ignored). + * No mismatch rejection — intermediaries may strip or rewrite the header. */ - private validateAndCopyAcceptLanguage(req: Request, messages: JSONRPCMessage[]): Response | undefined { + private copyAcceptLanguageFromHeader(req: Request, messages: JSONRPCMessage[]): void { const headerValue = req.headers.get('accept-language'); if (!headerValue) { - return undefined; + return; } for (const msg of messages) { @@ -925,22 +921,15 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { const params = msg.params as { _meta?: Record }; const metaValue = params._meta?.[ACCEPT_LANGUAGE_META]; - if (metaValue !== undefined && typeof metaValue === 'string') { - // Both present: check for mismatch (SEP-2243 HeaderMismatch) - if (metaValue !== headerValue) { - const error = `Bad Request: Accept-Language header "${headerValue}" does not match _meta["${ACCEPT_LANGUAGE_META}"] value "${metaValue}"`; - this.onerror?.(new Error(error)); - return this.createJsonErrorResponse(400, HEADER_MISMATCH_ERROR_CODE, error); - } - } else { - // Header present, _meta absent: copy header → _meta + if (metaValue === undefined || typeof metaValue !== 'string') { + // Header present, _meta absent: fall back to header value if (!params._meta) { params._meta = {}; } params._meta[ACCEPT_LANGUAGE_META] = headerValue; } + // If _meta is already set, it is canonical — ignore header } - return undefined; } /** diff --git a/packages/server/test/server/i18n.test.ts b/packages/server/test/server/i18n.test.ts index 5ea2d65e6d..065e13c808 100644 --- a/packages/server/test/server/i18n.test.ts +++ b/packages/server/test/server/i18n.test.ts @@ -6,12 +6,7 @@ import { randomUUID } from 'node:crypto'; import type { JSONRPCMessage } from '@modelcontextprotocol/core'; -import { - ACCEPT_LANGUAGE_META, - CONTENT_LANGUAGE_META, - HEADER_MISMATCH_ERROR_CODE, - setErrorContentLanguage -} 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'; @@ -157,7 +152,9 @@ describe('SEP-2792 i18n HTTP transport integration', () => { expect(resp.headers.get('content-language')).toBe('de'); }); - it('returns 400 with HeaderMismatch error code on header/_meta mismatch', async () => { + 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', { @@ -170,10 +167,31 @@ describe('SEP-2792 i18n HTTP transport integration', () => { ); const resp = await transport.handleRequest(req); - expect(resp.status).toBe(400); - const body = (await resp.json()) as { error?: { code?: number; message?: string } }; - expect(body.error?.code).toBe(HEADER_MISMATCH_ERROR_CODE); - expect(body.error?.message).toMatch(/does not match/); + 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 () => { From 13be363047326d1936c567449b4775ac8032f6de Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Fri, 29 May 2026 16:32:22 +0200 Subject: [PATCH 4/4] refactor(i18n): remove bare-header fallback from server transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SEP-2792 now defines _meta[acceptLanguage] as the ONLY canonical carrier. Servers MUST NOT treat a bare Accept-Language header (without _meta) as an MCP language preference. This removes the copyAcceptLanguageFromHeader method entirely — the server transport no longer reads Accept-Language on the inbound path at all. Client still mirrors _meta → Accept-Language on outbound (best-effort hint). Server still mirrors _meta[contentLanguage] → Content-Language on response. Refs: SEP commit 8e1b6f4d on SamMorrowDrums/modelcontextprotocol Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/server/src/server/streamableHttp.ts | 33 -------------------- packages/server/test/server/i18n.test.ts | 8 +++-- 2 files changed, 5 insertions(+), 36 deletions(-) diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index f1885853f9..65170b8074 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -9,7 +9,6 @@ import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestId, Transport } from '@modelcontextprotocol/core'; import { - ACCEPT_LANGUAGE_META, CONTENT_LANGUAGE_META, DEFAULT_NEGOTIATED_PROTOCOL_VERSION, getErrorContentLanguage, @@ -702,9 +701,6 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { } } - // SEP-2792: Copy Accept-Language header → _meta if _meta is absent - this.copyAcceptLanguageFromHeader(req, messages); - // check if it contains requests const hasRequests = messages.some(element => isJSONRPCRequest(element)); @@ -903,35 +899,6 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { return undefined; } - /** - * SEP-2792: Copies Accept-Language header into _meta when _meta is absent. - * If _meta[acceptLanguage] is already set, it takes precedence (the header is ignored). - * No mismatch rejection — intermediaries may strip or rewrite the header. - */ - private copyAcceptLanguageFromHeader(req: Request, messages: JSONRPCMessage[]): void { - const headerValue = req.headers.get('accept-language'); - if (!headerValue) { - return; - } - - for (const msg of messages) { - if (!('params' in msg) || !msg.params || typeof msg.params !== 'object') { - continue; - } - const params = msg.params as { _meta?: Record }; - const metaValue = params._meta?.[ACCEPT_LANGUAGE_META]; - - if (metaValue === undefined || typeof metaValue !== 'string') { - // Header present, _meta absent: fall back to header value - if (!params._meta) { - params._meta = {}; - } - params._meta[ACCEPT_LANGUAGE_META] = headerValue; - } - // If _meta is already set, it is canonical — ignore header - } - } - /** * SEP-2792: Extracts Content-Language value from response message(s) _meta. * Checks both successful results (_meta) and error responses (error.data._meta). diff --git a/packages/server/test/server/i18n.test.ts b/packages/server/test/server/i18n.test.ts index 065e13c808..7bd8bd9906 100644 --- a/packages/server/test/server/i18n.test.ts +++ b/packages/server/test/server/i18n.test.ts @@ -126,7 +126,8 @@ describe('SEP-2792 i18n HTTP transport integration', () => { await transport.close(); }); - it('mirrors Accept-Language header into _meta when only header is present', async () => { + 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' } @@ -136,8 +137,9 @@ describe('SEP-2792 i18n HTTP transport integration', () => { 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('Saluer'); - expect(body.result?._meta?.[CONTENT_LANGUAGE_META]).toBe('fr'); + // 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 () => {