Skip to content

Commit 5a5f548

Browse files
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>
1 parent cbd2d60 commit 5a5f548

8 files changed

Lines changed: 237 additions & 26 deletions

File tree

examples/client/src/i18nClient.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,13 @@
1111
* Run with stdio: tsx src/i18nClient.ts stdio
1212
*/
1313

14-
import { ACCEPT_LANGUAGE_META, Client, CONTENT_LANGUAGE_META, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
14+
import {
15+
ACCEPT_LANGUAGE_META,
16+
Client,
17+
CONTENT_LANGUAGE_META,
18+
getErrorContentLanguage,
19+
StreamableHTTPClientTransport
20+
} from '@modelcontextprotocol/client';
1521
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
1622

1723
const TEST_LANGUAGES = ['en', 'fr-CA,fr;q=0.9,en;q=0.5', 'ja'];
@@ -48,6 +54,20 @@ async function runWithTransport(
4854
const callContentLang = callResult._meta?.[CONTENT_LANGUAGE_META];
4955
console.log(` tools/call → text: "${text}"`);
5056
console.log(` contentLanguage: "${callContentLang}"`);
57+
58+
// Demonstrate localized error: call with empty name
59+
try {
60+
await client.callTool({
61+
name: 'get_greeting',
62+
arguments: { name: '' },
63+
_meta: { [ACCEPT_LANGUAGE_META]: lang }
64+
});
65+
} catch (error: unknown) {
66+
const err = error as { code?: number; message?: string; data?: unknown };
67+
const errorLang = getErrorContentLanguage(err.data);
68+
console.log(` tools/call (error) → message: "${err.message}"`);
69+
console.log(` contentLanguage: "${errorLang}"`);
70+
}
5171
console.log('');
5272
}
5373

examples/server/src/i18nExample.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,15 @@
1212
import { createMcpExpressApp } from '@modelcontextprotocol/express';
1313
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
1414
import type { CallToolResult, ListToolsResult } from '@modelcontextprotocol/server';
15-
import { ACCEPT_LANGUAGE_META, getAcceptLanguage, McpServer, negotiateLanguage, setContentLanguage } from '@modelcontextprotocol/server';
15+
import {
16+
ACCEPT_LANGUAGE_META,
17+
getAcceptLanguage,
18+
McpServer,
19+
negotiateLanguage,
20+
ProtocolError,
21+
setContentLanguage,
22+
setErrorContentLanguage
23+
} from '@modelcontextprotocol/server';
1624
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
1725
import * as z from 'zod/v4';
1826

@@ -35,6 +43,11 @@ const STRINGS: Record<string, Record<string, string>> = {
3543
en: 'Hello, {name}! Welcome.',
3644
fr: 'Bonjour, {name} ! Bienvenue.',
3745
de: 'Hallo, {name}! Willkommen.'
46+
},
47+
'error.name_required': {
48+
en: 'A name is required to generate a greeting.',
49+
fr: 'Un nom est requis pour générer un salut.',
50+
de: 'Ein Name ist erforderlich, um eine Begrüßung zu erzeugen.'
3851
}
3952
};
4053

@@ -97,6 +110,13 @@ function createI18nServer(): McpServer {
97110
const acceptLang = getAcceptLanguage(ctx.mcpReq as { _meta?: Record<string, unknown> }) ?? '';
98111
const lang = negotiateLanguage(acceptLang, AVAILABLE_LANGUAGES, 'en')!;
99112

113+
// Demonstrate localized error: empty name triggers a localized error response
114+
if (!name || name.trim() === '') {
115+
const errorMessage = t('error.name_required', lang);
116+
const errorData = setErrorContentLanguage({}, lang);
117+
throw new ProtocolError(-32_602, errorMessage, errorData);
118+
}
119+
100120
const result: CallToolResult = {
101121
content: [
102122
{

packages/client/src/client/streamableHttp.ts

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol
44
import {
55
ACCEPT_LANGUAGE_META,
66
createFetchWithInit,
7+
HEADER_MISMATCH_ERROR_CODE,
78
isInitializedNotification,
89
isJSONRPCErrorResponse,
910
isJSONRPCRequest,
@@ -233,25 +234,18 @@ export class StreamableHTTPClientTransport implements Transport {
233234
}
234235

235236
/**
236-
* Extracts the acceptLanguage value from message(s) _meta for header mirroring (SEP-2792).
237-
* For batched messages with differing values, returns the union of language ranges.
237+
* Extracts the acceptLanguage value from a message's _meta for header mirroring (SEP-2792).
238238
*/
239239
private _extractAcceptLanguage(message: JSONRPCMessage | JSONRPCMessage[]): string | undefined {
240-
const messages = Array.isArray(message) ? message : [message];
241-
const values: string[] = [];
242-
for (const msg of messages) {
243-
if ('params' in msg && msg.params && typeof msg.params === 'object') {
244-
const meta = (msg.params as { _meta?: Record<string, unknown> })._meta;
245-
if (meta && typeof meta[ACCEPT_LANGUAGE_META] === 'string') {
246-
values.push(meta[ACCEPT_LANGUAGE_META] as string);
247-
}
240+
const msg = Array.isArray(message) ? message[0] : message;
241+
if (!msg) return undefined;
242+
if ('params' in msg && msg.params && typeof msg.params === 'object') {
243+
const meta = (msg.params as { _meta?: Record<string, unknown> })._meta;
244+
if (meta && typeof meta[ACCEPT_LANGUAGE_META] === 'string') {
245+
return meta[ACCEPT_LANGUAGE_META] as string;
248246
}
249247
}
250-
if (values.length === 0) return undefined;
251-
// For batched messages with different values, union the language ranges
252-
if (values.length === 1) return values[0];
253-
const unique = [...new Set(values)];
254-
return unique.length === 1 ? unique[0] : unique.join(', ');
248+
return undefined;
255249
}
256250

257251
private async _startOrAuthSse(options: StartSSEOptions, isAuthRetry = false): Promise<void> {
@@ -576,7 +570,7 @@ export class StreamableHTTPClientTransport implements Transport {
576570
if (existingHeader && existingHeader !== metaAcceptLanguage) {
577571
throw new SdkError(
578572
SdkErrorCode.SendFailed,
579-
`Accept-Language header "${existingHeader}" conflicts with _meta["${ACCEPT_LANGUAGE_META}"] value "${metaAcceptLanguage}". They must be identical per SEP-2792.`
573+
`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}).`
580574
);
581575
}
582576
headers.set('accept-language', metaAcceptLanguage);

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,12 @@ export {
8080
CONTENT_LANGUAGE_META,
8181
getAcceptLanguage,
8282
getContentLanguage,
83+
getErrorContentLanguage,
84+
HEADER_MISMATCH_ERROR_CODE,
8385
negotiateLanguage,
8486
setAcceptLanguage,
85-
setContentLanguage
87+
setContentLanguage,
88+
setErrorContentLanguage
8689
} from '../../shared/i18n.js';
8790

8891
// URI Template

packages/core/src/shared/i18n.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@
1010

1111
import { match } from '@formatjs/intl-localematcher';
1212

13+
/**
14+
* JSON-RPC error code for header/body mismatch (SEP-2243).
15+
* Used when an HTTP header value disagrees with the corresponding `_meta` field.
16+
*/
17+
export const HEADER_MISMATCH_ERROR_CODE = -32_001;
18+
1319
/**
1420
* The `_meta` key for the client's language preference (request direction).
1521
* Value syntax matches the HTTP `Accept-Language` field (RFC 9110 §12.5.4).
@@ -58,6 +64,42 @@ export function setContentLanguage(result: { _meta?: Record<string, unknown> },
5864
result._meta[CONTENT_LANGUAGE_META] = value;
5965
}
6066

67+
/**
68+
* Reads `contentLanguage` from a JSON-RPC error's `data._meta`.
69+
* Per SEP-2792, localized error content uses `error.data._meta` since
70+
* the JSON-RPC Error object has no top-level `_meta`.
71+
*/
72+
export function getErrorContentLanguage(errorData: unknown): string | undefined {
73+
if (errorData && typeof errorData === 'object' && '_meta' in errorData) {
74+
const meta = (errorData as { _meta?: Record<string, unknown> })._meta;
75+
if (meta && typeof meta[CONTENT_LANGUAGE_META] === 'string') {
76+
return meta[CONTENT_LANGUAGE_META] as string;
77+
}
78+
}
79+
return undefined;
80+
}
81+
82+
/**
83+
* Sets `contentLanguage` on a JSON-RPC error data object's `_meta`.
84+
* Mutates the data object (creates `_meta` if absent).
85+
* If `data` is not an object, wraps it: `{ originalData, _meta: {...} }`.
86+
*
87+
* Returns the (possibly new) data object to assign back to `error.data`.
88+
*/
89+
export function setErrorContentLanguage(data: unknown, value: string): Record<string, unknown> {
90+
let obj: Record<string, unknown>;
91+
if (data && typeof data === 'object' && !Array.isArray(data)) {
92+
obj = data as Record<string, unknown>;
93+
} else {
94+
obj = data === undefined ? {} : { originalData: data };
95+
}
96+
if (!obj._meta || typeof obj._meta !== 'object') {
97+
obj._meta = {};
98+
}
99+
(obj._meta as Record<string, unknown>)[CONTENT_LANGUAGE_META] = value;
100+
return obj;
101+
}
102+
61103
/**
62104
* Parses an `Accept-Language` header value into an ordered list of locale tags.
63105
* Strips quality values and sorts by descending quality.

packages/core/test/shared/i18n.test.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@ import {
44
CONTENT_LANGUAGE_META,
55
getAcceptLanguage,
66
getContentLanguage,
7+
getErrorContentLanguage,
8+
HEADER_MISMATCH_ERROR_CODE,
79
negotiateLanguage,
810
setAcceptLanguage,
9-
setContentLanguage
11+
setContentLanguage,
12+
setErrorContentLanguage
1013
} from '../../src/shared/i18n.js';
1114

1215
describe('i18n helpers', () => {
@@ -117,4 +120,64 @@ describe('i18n helpers', () => {
117120
expect(negotiateLanguage('de-AT;q=0.8,fr-CA;q=0.9', available)).toBe('fr');
118121
});
119122
});
123+
124+
describe('HEADER_MISMATCH_ERROR_CODE', () => {
125+
it('equals -32001', () => {
126+
expect(HEADER_MISMATCH_ERROR_CODE).toBe(-32_001);
127+
});
128+
});
129+
130+
describe('getErrorContentLanguage', () => {
131+
it('returns undefined for null/undefined data', () => {
132+
expect(getErrorContentLanguage(undefined)).toBeUndefined();
133+
expect(getErrorContentLanguage(null)).toBeUndefined();
134+
});
135+
136+
it('returns undefined when data has no _meta', () => {
137+
expect(getErrorContentLanguage({ message: 'error' })).toBeUndefined();
138+
});
139+
140+
it('returns undefined when _meta has no contentLanguage', () => {
141+
expect(getErrorContentLanguage({ _meta: { other: 'x' } })).toBeUndefined();
142+
});
143+
144+
it('returns the contentLanguage from data._meta', () => {
145+
const data = { _meta: { [CONTENT_LANGUAGE_META]: 'fr' } };
146+
expect(getErrorContentLanguage(data)).toBe('fr');
147+
});
148+
149+
it('returns undefined for non-string contentLanguage', () => {
150+
const data = { _meta: { [CONTENT_LANGUAGE_META]: 123 } };
151+
expect(getErrorContentLanguage(data)).toBeUndefined();
152+
});
153+
});
154+
155+
describe('setErrorContentLanguage', () => {
156+
it('sets contentLanguage on an existing object', () => {
157+
const data = { message: 'err' };
158+
const result = setErrorContentLanguage(data, 'de');
159+
expect(result._meta).toBeDefined();
160+
expect((result._meta as Record<string, unknown>)[CONTENT_LANGUAGE_META]).toBe('de');
161+
expect(result.message).toBe('err');
162+
});
163+
164+
it('creates a wrapper object for non-object data', () => {
165+
const result = setErrorContentLanguage('raw string', 'fr');
166+
expect(result.originalData).toBe('raw string');
167+
expect((result._meta as Record<string, unknown>)[CONTENT_LANGUAGE_META]).toBe('fr');
168+
});
169+
170+
it('creates an empty object for undefined data', () => {
171+
const result = setErrorContentLanguage(undefined, 'en');
172+
expect((result._meta as Record<string, unknown>)[CONTENT_LANGUAGE_META]).toBe('en');
173+
expect(result.originalData).toBeUndefined();
174+
});
175+
176+
it('preserves existing _meta fields', () => {
177+
const data = { _meta: { other: 'value' } };
178+
const result = setErrorContentLanguage(data, 'de');
179+
expect((result._meta as Record<string, unknown>).other).toBe('value');
180+
expect((result._meta as Record<string, unknown>)[CONTENT_LANGUAGE_META]).toBe('de');
181+
});
182+
});
120183
});

packages/server/src/server/streamableHttp.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
ACCEPT_LANGUAGE_META,
1313
CONTENT_LANGUAGE_META,
1414
DEFAULT_NEGOTIATED_PROTOCOL_VERSION,
15+
getErrorContentLanguage,
16+
HEADER_MISMATCH_ERROR_CODE,
1517
isInitializeRequest,
1618
isJSONRPCErrorResponse,
1719
isJSONRPCRequest,
@@ -924,11 +926,11 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
924926
const metaValue = params._meta?.[ACCEPT_LANGUAGE_META];
925927

926928
if (metaValue !== undefined && typeof metaValue === 'string') {
927-
// Both present: check for mismatch
929+
// Both present: check for mismatch (SEP-2243 HeaderMismatch)
928930
if (metaValue !== headerValue) {
929931
const error = `Bad Request: Accept-Language header "${headerValue}" does not match _meta["${ACCEPT_LANGUAGE_META}"] value "${metaValue}"`;
930932
this.onerror?.(new Error(error));
931-
return this.createJsonErrorResponse(400, -32_000, error);
933+
return this.createJsonErrorResponse(400, HEADER_MISMATCH_ERROR_CODE, error);
932934
}
933935
} else {
934936
// Header present, _meta absent: copy header → _meta
@@ -943,6 +945,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
943945

944946
/**
945947
* SEP-2792: Extracts Content-Language value from response message(s) _meta.
948+
* Checks both successful results (_meta) and error responses (error.data._meta).
946949
* Returns the first contentLanguage value found, or undefined.
947950
*/
948951
private _extractContentLanguage(responses: JSONRPCMessage[]): string | undefined {
@@ -952,6 +955,11 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
952955
if (meta && typeof meta[CONTENT_LANGUAGE_META] === 'string') {
953956
return meta[CONTENT_LANGUAGE_META] as string;
954957
}
958+
} else if (isJSONRPCErrorResponse(msg)) {
959+
const lang = getErrorContentLanguage(msg.error?.data);
960+
if (lang) {
961+
return lang;
962+
}
955963
}
956964
}
957965
return undefined;

0 commit comments

Comments
 (0)