Conversation
`UriTemplate` copied literal runs verbatim, both into `expand()` and into
the pattern `match()` builds. RFC 6570 §3.1 requires a literal outside the
reserved/unreserved sets — `ucschar` (`café`), a space — to be pct-encoded
as UTF-8 on expansion, so `expand()` returned a string that is not a valid
RFC 3986 URI.
The consequence was a resource that could be listed but never read:
`resources/read` resolves the requested URI through `new URL()`, which
pct-encodes it, and the raw literal in the pattern could not match that.
A template such as `file:///docs/café/{name}` answered `-32602 Resource
not found` for every URI a client could send, encoded or raw.
Encode literals in both directions, passing existing `%XX` triplets
through unchanged so an already-encoded literal is not encoded twice
(`encodeURI` alone would turn `caf%C3%A9` into `caf%25C3%25A9`).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 37d9164 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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: |
`encodeURI` escapes `[` and `]`, so the previous commit turned the host of
`http://[::1]:8080/docs/{name}` into `%5B::1%5D` — which `new URL()` never
produces, leaving that template unmatchable.
RFC 3986 reserves both for the host literal and RFC 6570 §3.1 leaves reserved
characters to the template author, so restore them after `encodeURI`. The
restore runs per gap between `%XX` triplets, so a `%5B` the author wrote
themselves still passes through untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Literals come from the template, so re-encoding them on every expand() and match() repeated identical work: match() went from 1.28 to 2.35 µs per call on a two-variable template. Encoding them once in the constructor brings it back to 1.31 µs, and takes the encode call out of both hot paths. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
UriTemplatecopies literal runs verbatim — intoexpand()and into the patternmatch()builds. RFC 6570 §3.1 requires a literal outside the reserved/unreserved sets(
ucscharsuch ascafé, or a space) to be pct-encoded as UTF-8 when the template isexpanded, so
expand()currently returns a string that is not a valid RFC 3986 URI:Why it matters
The resource is listed and then unreadable — for every URI a client can send.
resources/readresolves the requested URI throughnew URL()before matching(
packages/server/src/server/mcp.ts:533→:555), andnew URL()pct-encodes the path.The pattern still holds the raw literal, so it cannot match the normalized URI. With a
template registered as
file:///docs/café/{name}, both of these fail today:The raw request fails too, because the server normalizes it before matching. Same for a
literal space (
file:///my docs/{name}). Non-ASCII path segments are ordinary fornon-English servers — this is where I hit it.
The change
encodeLiteral()applies §3.1 to literal runs in both directions, so an expanded URI —and the pct-encoded URI a
new URL()round-trip produces — matches the template it camefrom. Existing
%XXtriplets pass through unchanged, sinceencodeURIalone would turncaf%C3%A9intocaf%25C3%25A9. Reserved and unreserved characters stay as written.After the change both reads above succeed and return
uri: 'file:///docs/caf%C3%A9/a.txt'.Tests
packages/core-internal/test/shared/uriTemplate.test.ts: expansion (including theuritemplate-test "Literal Encoding" vector), no double-encoding, reserved characters
preserved, matching the encoded form, and an expand→match round-trip.
packages/server/test/server/resourceTemplateLiteralEncoding.test.ts: the end-to-endrouting case, asserted for both the pct-encoded and the raw request.
typecheckclean on both touched packages. The new unit assertions and three of thefour new server assertions fail on
main.Two follow-up commits come from reviewing the first myself.
encodeURIescapes[and], so an IPv6 host literal (http://[::1]:8080/docs/{name}) came out as%5B::1%5D, whichnew URL()never produces. RFC 3986 reserves both for the host and§3.1 leaves reserved characters alone, so they are restored after
encodeURI— per gapbetween
%XXtriplets, so an author-written%5Bstill passes through.And the literals are now encoded once in the constructor rather than on every call:
re-encoding per call took
match()from 1.28 to 2.35 µs on a two-variable template, anddoing it at construction brings it back to 1.31 µs.
Note on behavior
Through the server path nothing regresses — both request forms work after the change,
where both failed before. A direct
uriTemplate.match()call with a raw (unencoded)IRI now returns
nullinstead of matching; that string is not a valid RFC 3986 URI andis not what reaches a server, but I'm flagging it since
UriTemplateis exported.The Python SDK has the same defect and I reported it there:
modelcontextprotocol/python-sdk#3526.
Disclosure: found and written with Claude Code — it ran the official
uritemplate-test vectors against the
module, reduced the failure to this case, and drafted the patch and tests. I reviewed it
before opening and can walk through the change.