Skip to content

SEP-2792: Internationalization via Per-Request Language Negotiation#2792

Draft
SamMorrowDrums wants to merge 23 commits into
modelcontextprotocol:mainfrom
SamMorrowDrums:sammorrowdrums/sep-i18n-language-negotiation
Draft

SEP-2792: Internationalization via Per-Request Language Negotiation#2792
SamMorrowDrums wants to merge 23 commits into
modelcontextprotocol:mainfrom
SamMorrowDrums:sammorrowdrums/sep-i18n-language-negotiation

Conversation

@SamMorrowDrums

@SamMorrowDrums SamMorrowDrums commented May 26, 2026

Copy link
Copy Markdown
Contributor

Summary

A transport-agnostic i18n mechanism for MCP:

  • _meta['io.modelcontextprotocol/acceptLanguage'] on a request, value matches the HTTP Accept-Language field syntax verbatim (BCP 47 ranges + quality values, per RFC 9110 §12.5.4).
  • _meta['io.modelcontextprotocol/contentLanguage'] on a response, one or more BCP 47 tags indicating the language(s) actually returned (per RFC 9110 §8.5).
  • The mechanism is optional on both sides. Clients MAY send acceptLanguage; servers MAY ignore it. Nothing in this SEP requires any existing client or server to change behavior.
  • On Streamable HTTP, both fields are mirrored into the standard Accept-Language / Content-Language headers using the payload/header agreement rule SEP-2243 established for Mcp-Method / Mcp-Name. Comparison is against the parsed HTTP field-value (RFC 9110 §5.5, so surrounding OWS is stripped and repeated field lines are joined with , before comparison); the server tolerates header absence (CDN strip) but rejects on mismatch.
  • Per-request scope, not handshake-bound, so users can change language between requests without any renegotiation — aligned with SEP-2575 (stateless-by-default).

Scope of translation

The SEP defines three explicit buckets, anchored to the schema's own field-level classification:

  • MUST translate when honoring acceptLanguage: BaseMetadata.title on every type that extends it, ToolAnnotations.title, ElicitRequestFormParams.message / ElicitRequestURLParams.message, elicitation form-schema title/description, ProgressNotification.message, JSONRPCError.message when intended for display.
  • MAY translate, with caveat (these are model-facing or dual-purpose; translating changes what the LLM sees): Tool.description, Resource.description, ResourceTemplate.description, Prompt.description, PromptArgument.description, Implementation.description, property-level title/description inside Tool.inputSchema/outputSchema, body content of tools/call / resources/read / prompts/get (including MCP Apps UI-resource bodies), LoggingMessageNotification.data when contractually user-facing.
  • MUST NOT translate: BaseMetadata.name, URIs and URI templates, MIME types, JSON Schema property keys, enum tokens, capability identifiers, error code values, method names, _meta keys.

Motivation

Converts the docs-only proposal in #2355 into a cross-transport SEP, addressing reviewer feedback there:

  • @pja-ant: a header-only solution leaves stdio without an i18n story → resolved by defining the field in _meta first, with HTTP headers as a mirror.
  • @kurtisvg: "minimize the gaps between transports … put it in _meta and mirror to the header like we're doing for some other values" → exactly the model adopted here.

Motivating precedents

  • SEP-2243 (HTTP Header Standardization) establishes the payload/header mirroring rule this SEP extends to Accept-Language / Content-Language.
  • SEP-2575 (Stateless MCP) mandates transport consistency and removes initialize as a place for persistent negotiated state; per-request language preference is the natural fit.
  • SEP-414 (request.params._meta) establishes _meta as the carrier for per-request metadata.
  • SEP-2133 (Extensions) provides the io.modelcontextprotocol/ vendor prefix.

Design choices worth flagging

  1. Comparison is against the parsed HTTP field-value, not the raw wire bytes. Per RFC 9110 §5.5, surrounding OWS is not part of the field value and multiple field lines are joined with , to form the field value. Comparison is then byte-equality on the decoded JSON string vs. that field value. This is unambiguous, matches what any RFC-compliant HTTP stack already yields, and stays a one-line check. Trade-off: operators using header-rewriting CDN features (Fastly accept.language_lookup, Varnish vmod_accept) need a one-time configuration change; a dedicated Normalization footgun section spells this out.
  2. Header absence is tolerated, mismatch is rejected. CloudFront's default behaviour strips Accept-Language. Rejecting on absence would lock out operators behind such a CDN even when the caller didn't intend to negotiate. Tolerating absence preserves the routing guarantee for callers that do supply the header and falls back to _meta cleanly otherwise.
  3. HeaderMismatch error code is -32020, settled, matching the schema-level reservation now on main (schema/draft/schema.ts, HEADER_MISMATCH = -32020). Earlier revisions of this PR carried a provisional -32005 pending SEP-2243, SEP-2678, and #2642 — that reservation is now in the schema and the SEP tracks it.
  4. error.message is localized in place. JSON-RPC 2.0 defines error.code as the machine-interpreted identifier and error.message as the display string; a parallel localizedMessage field would just duplicate that. Clients MUST NOT branch on error.message text; dispatch is on code. When the server localized error.message, it MUST also set error.data._meta['io.modelcontextprotocol/contentLanguage'].

Relationship to SEP-1809

SEP-1809 proposed a clientContext object on tools/call carrying preferredLanguages and other client context. This SEP is deliberately narrower and orthogonal: it standardizes language negotiation only (nothing else about client context), covers every request (not just tools/call), and reuses IETF machinery unchanged. SEP-1809 can proceed independently for non-language client-context fields if the WG wants; the language field itself is subsumed here.

Transports WG review

Cross-posted to and reviewed in modelcontextprotocol/transports-wg#42. All WG feedback (RFC-citation cleanup, exact-comparison algorithm, -32020 alignment, error.message in-place localization) is folded into the head of this PR.

Reference implementation

modelcontextprotocol/typescript-sdk#2158 (draft) exercises every conformance scenario in the SEP over both Streamable HTTP and stdio, including the exact-field-value comparison edge cases.

AI disclosure

This SEP was authored with assistance from GitHub Copilot.

@SamMorrowDrums

Copy link
Copy Markdown
Contributor Author

Hi @kurtisvg, @pja-ant, tagging you both directly given your feedback on #2355, which is the seed for this SEP (indeed I believe we discussed at MCP Dev Summit that I would look at converting this to a SEP in order to include the metadata for all transports).

  1. Fully opt-in on both sides. Clients MAY send acceptLanguage; servers MAY ignore it entirely, with no capability flag or handshake change. Nothing in the SEP requires any existing implementation to change.
  2. No reinvention. The motivation leans hard on the fact that BCP 47, RFC 4647 matching, Accept-Language / Content-Language, CDN Vary behavior, and every framework-level i18n library (Intl.LocaleMatcher, golang.org/x/text/language, ICU, Babel, gettext, framework localization modules) already exist and are battle-tested. The SEP standardizes only the carrier; the value is the exact HTTP Accept-Language syntax verbatim, so it goes straight into existing matchers.
  3. Addresses the docs(spec): add internationalization guidance for Streamable HTTP #2355 pushback head-on.
    • @pja-ant on transport parity: language is defined as a transport-agnostic _meta field first. stdio and any future transport get full i18n with zero transport changes. HTTP gets the standard headers as a strict mirror, not the source of truth.
    • @kurtisvg on "put it in _meta and mirror to the header like we're doing for some other values": that is exactly the model, applying the SEP-2243 header-mirroring pattern and reusing its strict-mismatch rule verbatim.
  4. Per-request, not handshake-bound. Citing SEP-2575: the field is sent on every request, so language can change mid-conversation without renegotiation. This avoids reintroducing the kind of session state SEP-2575 is removing, and supports the casual agent-loop notion of a "session" where the user might switch UI language between turns.

Scope is intentionally narrow: user-facing strings (titles, descriptions, UI-bound errors, user-visible notifications) are the primary target; servers MAY also translate body content.

Reference implementation is up as a draft on the TypeScript SDK: modelcontextprotocol/typescript-sdk#2158. It includes the core _meta helpers (wrapping @formatjs/intl-localematcher, no bespoke matcher), Streamable HTTP header/_meta mirroring with the strict-mismatch rule, stdio pass-through, an en/fr/de example server + client across both transports, and unit + integration tests, including the per-request mid-session language switch proof on stdio. All builds, typechecks, lints and tests pass.

Would either of you be willing to sponsor?

Adds a transport-agnostic, fully opt-in i18n mechanism for MCP using
_meta['io.modelcontextprotocol/acceptLanguage'] on requests and
_meta['io.modelcontextprotocol/contentLanguage'] on responses, mirrored
into the standard HTTP Accept-Language / Content-Language headers on the
Streamable HTTP transport with a strict-mismatch rule consistent with
SEP-2243.

Per-request scope (no handshake-bound state) aligns with SEP-2575 and
supports mid-conversation language switching. Reuses BCP 47, RFC 4647
language-range matching, and existing ecosystem libraries verbatim,
no bespoke matcher or schema.

Supersedes modelcontextprotocol#2355. Proposes subsuming the locale aspect of SEP-1809.

Reference implementation:
modelcontextprotocol/typescript-sdk#2158 (en/fr/de server + client,
stdio and Streamable HTTP, unit and integration tests including
mid-session language switch on stdio).

Co-authored-by: Copilot <[email protected]>
@SamMorrowDrums
SamMorrowDrums force-pushed the sammorrowdrums/sep-i18n-language-negotiation branch from ead3ab8 to 958a569 Compare May 27, 2026 08:00

@pja-ant pja-ant left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean, well-scoped, and I think the shape is right: per-request language in _meta, mirrored to the HTTP headers, no session state. Lines up with SEP-2243 and SEP-2575, reuses BCP 47 / Accept-Language rather than inventing anything. No objections to the design — and the scary-sounding mismatch-rule concerns don't hold up, since the 400 only fires when both the header and body _meta are present and disagree (and _meta isn't intermediary-rewritable).

Two things to fix first:

  • Drop the Batching section. We removed JSON-RPC batching — one message per POST, no batch type in the schema — so the "union of language ranges" MUST describes a wire shape the spec forbids. (And a union header would mismatch every per-message _meta, tripping the very 400 it's trying to avoid.)
  • contentLanguage on errors has nowhere to live. Error is { code, message, data? } — no _meta — and your own illustrative schema only adds _meta to Result. Pick a home (add _meta to Error, or use error.data) and spell it out, since localized errors are a primary use case.

Minor: name the mismatch code explicitly (-32001 HeaderMismatch, now in the draft spec — the TS impl drifted to SendFailed); and "reused unchanged" undersells it — you're extending the 2243 rule to a standard header, worth a line in Security Implications.

Structure's all there, file/docs are right. Just needs a sponsor and (for Final) a conformance scenario per SEP-2484.

- Drop Batching section (JSON-RPC batching is removed from MCP)
- Define error-response localization via error.data._meta
- Name mismatch error explicitly as -32001 HeaderMismatch
- Reframe SEP-2243 relationship as extending its rule and error code
- Add SEP-2484 conformance scenario outline

Co-authored-by: Copilot <[email protected]>
@SamMorrowDrums

SamMorrowDrums commented May 27, 2026

Copy link
Copy Markdown
Contributor Author

Feedback applied @pja-ant - thank you for the thorough review.

Changes in b5667477:

  • Dropped the Batching section. You're right, batching is gone from the spec and the union-of-ranges MUST described a forbidden wire shape.
  • Localized errors have a home. Added an Error responses subsection using error.data._meta['io.modelcontextprotocol/contentLanguage'] (the JSON-RPC data extension point, so no schema change to Error), with an example and the mirror rule for the Content-Language HTTP response header.
  • Named the mismatch code explicitly: -32001 HeaderMismatch throughout Specification, Rationale, and Security Implications.
  • Reframed the SEP-2243 relationship as extending the rule and the error code to the standard Accept-Language / Content-Language surface, with a corresponding line in Security Implications (the broader intermediary surface is exactly what makes the extension worth calling out).
  • Added a Conformance section per SEP-2484 outlining six scenarios required for Final, including header/body mismatch returning -32001, error-response localization, and per-request switching on stdio.

The TS SDK reference implementation (modelcontextprotocol/typescript-sdk#2158) is being updated in lockstep: switching the placeholder SdkErrorCode.SendFailed to -32001 HeaderMismatch, implementing error.data._meta localization with HTTP Content-Language mirroring on errors, and demonstrating both in the example server/client with tests.

@pja-ant pja-ant changed the title SEP: Internationalization via Per-Request Language Negotiation SEP-2792: Internationalization via Per-Request Language Negotiation May 28, 2026
Comment thread seps/2792-i18n-language-negotiation.md Outdated
Accept-Language/Content-Language are routinely stripped, normalized, or
rewritten by intermediaries (CloudFront default behavior, Fastly
accept.language_lookup, Varnish vmod_accept). RFC 9110 also lacks a
canonical byte-equality form for Accept-Language. Applying SEP-2243's
hard-fail rule to these headers would error out exactly the edge-i18n
deployments we cite as motivating.

- _meta is now canonical; headers are best-effort hints (SHOULD mirror)
- Remove -32001 HeaderMismatch reject path entirely
- Reframe Why-mirror rationale around expected intermediary rewriting
- Replace mismatch-attack security note with header-tampering-expected
- Replace conformance scenario 4 (reject mismatch) with one asserting
  that a stripped/rewritten Accept-Language MUST NOT cause rejection
- Update reference-implementation summary to drop mismatch claims

Co-authored-by: Copilot <[email protected]>
@SamMorrowDrums

Copy link
Copy Markdown
Contributor Author

Feedback applied @pja-ant - and you're right, this is much cleaner.

63dc3e1a drops the strict header/body mismatch rule for Accept-Language / Content-Language entirely. The new model:

  • _meta is canonical; if present, the server uses it and ignores the header.
  • HTTP headers are a best-effort mirror (SHOULD, not MUST), intended as a hint for caches/CDNs/observability, with the explicit acknowledgement that intermediaries may strip, normalize, or rewrite them. CloudFront's default behaviour and Fastly's accept.language_lookup / Varnish's vmod_accept are called out by name in the rationale.
  • No reject path, no -32001, no header/body equality contract to violate (which conveniently also sidesteps RFC 9110's lack of a canonical serialization for Accept-Language).
  • Security Implications now treats intermediary header rewriting as expected behaviour rather than as an attack.
  • Conformance scenario 4 (mismatch returns 400) is replaced with a scenario asserting that a stripped/rewritten Accept-Language MUST NOT cause rejection.
  • Reference-implementation summary updated to drop mismatch claims; the TS SDK PoC (SEP-2792: Reference implementation for per-request language negotiation typescript-sdk#2158) is being updated in lockstep (removing the server reject branch, the client throw, and the -32001 constant; adding a stripped-header positive test).

This also sidesteps the -32001 vs SDK conventions discussion entirely, since we no longer need a code at all.

The PoC implementation notes (build status, deferred mismatch rule
rationale, SSE vs JSON header behavior) belong in the PR description,
not in the SEP itself. Keep the SEP focused on the protocol design.

Co-authored-by: Copilot <[email protected]>
@SamMorrowDrums
SamMorrowDrums requested a review from pja-ant May 29, 2026 14:38
@SamMorrowDrums SamMorrowDrums added the draft SEP proposal with a sponsor. label May 29, 2026
Comment thread seps/2792-i18n-language-negotiation.md Outdated
- **Edge i18n services** that route requests to language-specific backends.
- **Observability tools** that segment usage by locale.

This SEP therefore says clients SHOULD mirror `_meta[acceptLanguage]` into

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be clients MUST add the header, while servers MAY allow the request if the header is missing but MUST reject the request if the header is present and not matching.

SamMorrowDrums and others added 6 commits June 1, 2026 23:01
Per WG feedback (Kurtis), revert the bare-header-tolerated design and
restore strict byte-equality between _meta and the corresponding HTTP
header on both request (Accept-Language) and response (Content-Language)
sides, with header absence on requests tolerated for CDN compatibility.

Use error code -32005 for HeaderMismatch instead of -32001, which is
already in conflicting use across SDKs (REQUEST_TIMEOUT in Python and
Kotlin vs HeaderMismatch in Go and C#); the code is provisional pending
SEP-2243 / SEP-2678 / PR modelcontextprotocol#2642 schema-level reservation work.

Add a Normalization footgun section covering Fastly, Varnish, CloudFront
and reverse-proxy header rewriting; consolidate operator-config detail
in that section so Security and Backward Compatibility just link to it.

Co-authored-by: Copilot <[email protected]>
- Split the bundled 'header absent / bare header' bullet into two
  distinct rules so each cell of the (_meta-present, header-present)
  matrix has its own normative line.
- Trim the Streamable HTTP intro to one opinionated paragraph.
- Drop the trailing 'no new attack surface' platitude from Security.
- Promote 'caches must Vary' from a reminder to a MUST.
- Replace 'Tentative answer' hedging in Open Questions with explicit
  proposed resolutions.
- Reference Implementation point 5 reads as a factual list rather
  than a results pitch.
- Add [RFC 9111] reference link.

Co-authored-by: Copilot <[email protected]>
The MUST NOT rule lives in the Specification section that immediately
follows; the cross-reference adds no information.

Co-authored-by: Copilot <[email protected]>
The bullet list and coordination note restated the preceding paragraph.

Co-authored-by: Copilot <[email protected]>
The detailed bullet list duplicated the linked PR's description and
will rot. Keep the SEP terse; the PR is the source of truth.

Co-authored-by: Copilot <[email protected]>
The Vary requirement now lives normatively in the Response section.
Security bullet shortened to a pointer. The Open Questions entry is
resolved (MUST) and dropped.

Co-authored-by: Copilot <[email protected]>
SamMorrowDrums and others added 2 commits June 1, 2026 23:32
Drop the bold leading-phrases on 6 and 7 so all seven scenarios use
the same plain-bullet style.

Co-authored-by: Copilot <[email protected]>
@localden localden added the SEP label Jun 8, 2026
@localden localden added proposal SEP proposal without a sponsor. and removed draft SEP proposal with a sponsor. labels Jun 8, 2026
@SamMorrowDrums
SamMorrowDrums marked this pull request as draft June 17, 2026 20:34
Addresses @kurtisvg feedback on transports-wg#42:

- Replaces the open-ended "any field the host displays" catch-all with
  an explicit, enumerated list anchored to the schema's own classification
  of each field (display-only vs "hint to the model").
- Makes the consequence of translating model-facing fields (Tool.description,
  inputSchema descriptions, etc.) explicit, with a concrete example from
  the Codex implementation, instead of handwaving.
- Calls out MCP Apps UI resource bodies (SEP-1865) by name.
- Defers schema property descriptions (dual-purpose model + human) to
  MAY with a stated caveat rather than a separate SEP.

Co-authored-by: Copilot <[email protected]>
SamMorrowDrums and others added 3 commits June 29, 2026 15:15
- Resolve merge with main.
- Drop duplicate (and now-stale) MUST NOT translate list from the
  acceptLanguage subsection; the Scope section is the single source of
  truth.
- Fix broken anchor to the renamed Scope heading.
- Resolve the open question on notifications into the Specification:
  notifications carrying user-facing text MUST set
  params._meta[contentLanguage]. Open Questions section removed.
- Make the error.message localization choice explicit (server may
  localize message inline or carry the translated text in a structured
  error.data field).
- Update SEP-2243 and SEP-2575 link targets from PRs to the merged
  seps/ files.
- Trim the Reference Implementation paragraph.

Co-authored-by: Copilot <[email protected]>
Bring PR modelcontextprotocol#2792 up to date with upstream main (163 commits) and replace
seps/2792-i18n-language-negotiation.md with the Transports WG-reviewed
authoritative source at SamMorrowDrums/transports-wg@c85169e
(proposals/XXXX-i18n-language-negotiation.md).

Highlights vs previous PR head (94cc51c):

- HeaderMismatch error code is now the settled `-32020` (matches the
  reservation now live in schema/draft/schema.ts on main), replacing the
  provisional `-32005`.
- Exact HTTP field-value comparison algorithm: comparison happens on the
  decoded JSON string vs. the parsed HTTP field-value (per RFC 9110
  §5.5), so surrounding OWS is stripped and repeated field lines are
  joined with `, ` before byte-comparison. Explicit worked examples for
  OWS, case, q-value zero-padding, and multi-line fields added to
  Conformance.
- Corrected RFC/citation semantics throughout (RFC 9110 §5.3, §5.5,
  §12.5.4, §12.5.5; RFC 9111 §4.1; RFC 5646 §2.1.1) and clarified
  language-matching reuse (server hands the value verbatim to any RFC
  4647 matcher; no bespoke matching defined here).
- error.message localized in place (no parallel field), with the
  contract that error.code remains the machine-interpreted identifier
  and clients MUST NOT branch on error.message text.
- Notifications rule promoted from Open Questions into Specification.
- Scope restated as three explicit MUST / MAY-with-caveat / MUST-NOT
  buckets, anchored to the schema's own field-level classification.

Regenerated docs/seps/2792-i18n-language-negotiation.mdx and refreshed
docs/seps/index.mdx and docs/docs.json via `npm run generate:seps`.
`npm run check:seps` clean.

Supersedes stash "sep-2792 error.message edit, pending WG outcome".
@SamMorrowDrums SamMorrowDrums added draft SEP proposal with a sponsor. and removed draft SEP proposal with a sponsor. labels Jul 21, 2026
These four SEPs (1330, 1577, 2322, 2663) arrived from upstream/main with
pre-existing prettier drift in their TypeScript code fences (missing
leading `|` in union types). The Markdown Format Check CI job on this PR
fails on them even though the changes are unrelated to SEP-2792. Apply
`prettier --write` to satisfy CI; content is unchanged (union-type
formatting only). No mdx regeneration needed — the rendered mdx already
matches.

Also verified:
- npm run check:seps: clean
- npm run check:docs:format: clean
Prettier 3.9.5 (pinned in package.json) formats these differently than
3.8.3 did (which was in this workspace's stale node_modules when the
previous commit ran). CI runs `npm ci` so it uses 3.9.5 and rejects the
un-reformatted files. Apply `npm run format:docs` so both md and mdx
match the pinned prettier version.

Verified:
- npm run check:docs:format: clean
- npm run generate:seps: clean
- npm run check:seps: clean
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

proposal SEP proposal without a sponsor. SEP

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

5 participants