fix(server): validate Content-Type by parsed media type instead of substring match#2441
Conversation
The Content-Type checks in the Streamable HTTP server transport and the hono adapter matched the raw header as a substring, accepting values whose media type is not application/json (e.g. 'text/plain; a=application/json') while rejecting valid case variants; the 2026-07-28 entry did not validate Content-Type at all. Parse the header (content-type package, with a media-type fallback for malformed parameter sections) and compare the media type at every entry. Export isJsonContentType for adapter authors, migrate the client's response dispatch to the same comparison, and add an ESLint rule banning the substring pattern.
🦋 Changeset detectedLatest commit: b2546a1 The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/core
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
…ype-media-type-validation
| * behavior, so a header whose media type is unambiguous is not rejected for | ||
| * a sloppy parameter section. | ||
| */ | ||
| export function mediaTypeEssence(header: string | null | undefined): string | undefined { |
There was a problem hiding this comment.
This wording is actually straight from WHATWG MIME:
https://mimesniff.spec.whatwg.org/#mime-type-essence
https://fetch.spec.whatwg.org/#cors-safelisted-request-header
The essence fallback took everything before the first ';', so a joined duplicate header was rejected when the first copy had no parameters but accepted when it did. Bail out of the fallback when the parameter tail of an unparseable value contains a comma, and cite the WHATWG essence definition in the module docs.
There was a problem hiding this comment.
The joined-duplicate-header asymmetry from the earlier inline comment is now handled in mediaTypeEssence() and pinned by tests, and I didn't find further issues — but since this adds a new public export (isJsonContentType), a new core-internal runtime dependency, and stricter 415 behavior on the 2026-07-28 entry, it deserves a maintainer's judgment call rather than an automated approval.
Extended reasoning...
Overview
The PR replaces substring-matching of application/json with parsed-media-type comparison across the server transport (WebStandardStreamableHTTPServerTransport), createMcpHandler (which previously performed no Content-Type check on the modern leg), the hono adapter's JSON pre-parse, and the client's response dispatch. It introduces packages/core-internal/src/shared/mediaType.ts (mediaTypeEssence / isJsonContentType), exports isJsonContentType on the public surface, adds the content-type package as a core-internal runtime dependency (moved to the runtimeShared catalog), adds an ESLint no-restricted-syntax rule banning the substring pattern, and includes migration-guide and changeset prose plus new tests for both eras and the direct transport wiring.
Security risks
Low. The change is strictly tightening: non-JSON media types (including values that merely contain the substring) now answer 415 before dispatch, and the earlier concern — that the malformed-parameter fallback could accept a joined duplicate header whose first copy carried parameters — is resolved: the fallback now returns undefined when a comma appears in the parameter tail, with tests pinning both duplicate-header shapes. The Accept-header substring check is intentionally retained (comma-separated list semantics) with an explicit eslint-disable and comment. No auth, crypto, or data-exposure surface is touched.
Level of scrutiny
This is production-critical HTTP entry code and a behavior change with mild breaking potential: hand-rolled clients that omitted or mis-set Content-Type on the 2026-07-28 entry now get 415 where they previously got served. The repo's conventions place the burden of proof on new public exports (isJsonContentType is now public API, justified by the hono adapter and example callsites in the same PR) and on new dependencies in the runtime-neutral core package — both are reasonable here (content-type is tiny and dependency-free, and the legacy SSE transport already used it), but they are exactly the kind of API-surface and posture decisions a maintainer should sign off on rather than a bot.
Other factors
Test coverage is thorough: the new mediaType.test.ts and contentTypeValidation.test.ts pin the substring-misclassification shape, parameter/case variants, malformed parameter sections, missing headers, joined duplicates, and both legacy and modern legs including the direct-transport wiring. The changeset and migration-guide prose match the implemented behavior, including the note that custom compositions of classifyInboundRequest / PerRequestHTTPServerTransport must apply the check themselves. The prior automated inline finding has been addressed in the current revision, so no outstanding review comments remain from this system.
Content-Type validation in the Streamable HTTP server transport, the
createMcpHandlerentry, and the hono adapter's JSON pre-parse matched the raw header as a substring (ct.includes('application/json')). That misclassifies values whose media type is notapplication/json(for exampletext/plain; a=application/json), wrongly rejects valid case variants likeApplication/JSON, and the 2026-07-28 entry performed no Content-Type validation at all.Motivation and Context
Content-Type is a media type plus optional parameters (RFC 9110); comparisons must use the parsed media type. This parses the header (via the
content-typepackage the legacy SSE transport already used, with a media-type fallback for malformed parameter sections so unambiguous values likeapplication/json;keep working) and compares the media type itself at every entry. A newisJsonContentTypehelper is exported for transport and framework-adapter authors, the client's response dispatch uses the same comparison, and an ESLint rule bans the substring pattern from reappearing.How Has This Been Tested?
application/jsonare served; absent and duplicated headers answer 415.Breaking Changes
Stricter for non-conforming clients only: POSTs whose Content-Type media type is not
application/jsonnow answer 415 on every entry (previously any value containing the substring passed, and the 2026-07-28 entry did not check at all — see the migration guide note). SDK clients are unaffected. One leniency gain: case variants are now accepted per RFC 9110.Types of changes
Checklist