From 29612780bd02b19ac60c6a30ba727a76bc70d753 Mon Sep 17 00:00:00 2001 From: LGUIUX <146765771+LGUIUX@users.noreply.github.com> Date: Thu, 17 Sep 2026 20:50:49 -0400 Subject: [PATCH 1/3] =?UTF-8?q?fix(uri-template):=20pct-encode=20literals?= =?UTF-8?q?=20per=20RFC=206570=20=C2=A73.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- .changeset/uri-template-literal-encoding.md | 11 +++ .../core-internal/src/shared/uriTemplate.ts | 30 ++++++- .../test/shared/uriTemplate.test.ts | 30 +++++++ .../resourceTemplateLiteralEncoding.test.ts | 84 +++++++++++++++++++ 4 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 .changeset/uri-template-literal-encoding.md create mode 100644 packages/server/test/server/resourceTemplateLiteralEncoding.test.ts diff --git a/.changeset/uri-template-literal-encoding.md b/.changeset/uri-template-literal-encoding.md new file mode 100644 index 0000000000..9888ecc46e --- /dev/null +++ b/.changeset/uri-template-literal-encoding.md @@ -0,0 +1,11 @@ +--- +'@modelcontextprotocol/core-internal': patch +--- + +Pct-encode URI template literals per RFC 6570 §3.1, so a resource template whose literal text carries a character the URI grammar does not allow stays routable. + +`UriTemplate` copied literal runs verbatim, in both `expand()` and the pattern `match()` builds. §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 not. + +Literals are now encoded in both directions, with existing `%XX` triplets passing through unchanged so an already-encoded literal is not encoded twice. diff --git a/packages/core-internal/src/shared/uriTemplate.ts b/packages/core-internal/src/shared/uriTemplate.ts index 5ffe213acd..5d0790a01e 100644 --- a/packages/core-internal/src/shared/uriTemplate.ts +++ b/packages/core-internal/src/shared/uriTemplate.ts @@ -24,6 +24,30 @@ export class UriTemplate { throw new Error(`${context} exceeds maximum length of ${max} characters (got ${str.length})`); } } + + /** + * Percent-encodes a literal run per RFC 6570 §3.1. + * + * A template may be written with characters the URI grammar does not allow — + * `ucschar` (`file:///docs/café/`) or a space — and §3.1 requires those to be + * pct-encoded as UTF-8 when the template is expanded. Reserved and unreserved + * characters are structural and stay as written, and existing `%XX` triplets + * pass through unchanged so an already-encoded literal is not encoded twice + * (`encodeURI` alone would turn `caf%C3%A9` into `caf%25C3%25A9`). + * + * Applied to both directions: `expand()` emits the encoded form and `match()` + * builds its pattern from it, so an expanded URI matches the template it came + * from — as does the pct-encoded URI a `new URL()` round-trip produces. + */ + private static encodeLiteral(text: string): string { + let result = ''; + let last = 0; + for (const match of text.matchAll(/%[0-9A-Fa-f]{2}/g)) { + result += encodeURI(text.slice(last, match.index)) + match[0]; + last = match.index + match[0].length; + } + return result + encodeURI(text.slice(last)); + } private readonly template: string; private readonly parts: Array; @@ -175,7 +199,7 @@ export class UriTemplate { for (const part of this.parts) { if (typeof part === 'string') { - result += part; + result += UriTemplate.encodeLiteral(part); continue; } @@ -259,7 +283,9 @@ export class UriTemplate { for (const part of this.parts) { if (typeof part === 'string') { - pattern += this.escapeRegExp(part); + // Encoded so the pattern lines up with what expand() emits: the + // server matches against a `new URL()` round-trip (RFC 6570 §3.1). + pattern += this.escapeRegExp(UriTemplate.encodeLiteral(part)); } else { const patterns = this.partToRegExp(part); for (const { pattern: partPattern, name } of patterns) { diff --git a/packages/core-internal/test/shared/uriTemplate.test.ts b/packages/core-internal/test/shared/uriTemplate.test.ts index bfc3237872..42116e70a4 100644 --- a/packages/core-internal/test/shared/uriTemplate.test.ts +++ b/packages/core-internal/test/shared/uriTemplate.test.ts @@ -85,6 +85,36 @@ describe('UriTemplate', () => { }); }); + describe('literal encoding (RFC 6570 §3.1)', () => { + it('should pct-encode a literal the URI grammar does not allow', () => { + // uritemplate-test, "Literal Encoding" + expect(new UriTemplate('café/{v}').expand({ v: 'value' })).toBe('caf%C3%A9/value'); + expect(new UriTemplate('file:///my docs/{v}').expand({ v: 'a.txt' })).toBe('file:///my%20docs/a.txt'); + }); + + it('should keep reserved and unreserved literal characters as written', () => { + expect(new UriTemplate('file:///a-b_c~d.e/{v}').expand({ v: 'x' })).toBe('file:///a-b_c~d.e/x'); + expect(new UriTemplate('http://x.test/p?q=1&r=2#{v}').expand({ v: 'x' })).toBe('http://x.test/p?q=1&r=2#x'); + }); + + it('should not encode an already-encoded literal twice', () => { + expect(new UriTemplate('file:///docs/caf%C3%A9/{v}').expand({ v: 'a.txt' })).toBe('file:///docs/caf%C3%A9/a.txt'); + }); + + it('should match the pct-encoded form a URL round-trip produces', () => { + const template = new UriTemplate('file:///docs/café/{name}'); + expect(new URL('file:///docs/café/a.txt').href).toBe('file:///docs/caf%C3%A9/a.txt'); + expect(template.match('file:///docs/caf%C3%A9/a.txt')).toEqual({ name: 'a.txt' }); + }); + + it('should round-trip expand through match', () => { + for (const source of ['file:///docs/café/{name}', 'file:///my docs/{name}']) { + const template = new UriTemplate(source); + expect(template.match(template.expand({ name: 'a.txt' }))).toEqual({ name: 'a.txt' }); + } + }); + }); + describe('matching', () => { it('should match simple strings and extract variables', () => { const template = new UriTemplate('http://example.com/users/{username}'); diff --git a/packages/server/test/server/resourceTemplateLiteralEncoding.test.ts b/packages/server/test/server/resourceTemplateLiteralEncoding.test.ts new file mode 100644 index 0000000000..af64e588f1 --- /dev/null +++ b/packages/server/test/server/resourceTemplateLiteralEncoding.test.ts @@ -0,0 +1,84 @@ +/** + * A resource template whose literal text carries characters the URI grammar does not + * allow — `ucschar` (`café`) or a space — stays routable. + * + * `resources/read` resolves the requested URI through `new URL()` before matching, so + * the template has to line up with the pct-encoded form (RFC 6570 §3.1), whichever form + * the client sent. + */ +import type { JSONRPCRequest, MessageClassification } from '@modelcontextprotocol/core-internal'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + PROTOCOL_VERSION_META_KEY, + setNegotiatedProtocolVersion +} from '@modelcontextprotocol/core-internal'; +import { describe, expect, it } from 'vitest'; + +import { invoke } from '../../src/server/invoke'; +import { McpServer, ResourceTemplate } from '../../src/server/mcp'; + +const MODERN_REVISION = '2026-07-28'; +const MODERN: MessageClassification = { era: 'modern', revision: MODERN_REVISION }; + +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, + [CLIENT_INFO_META_KEY]: { name: 'literal-encoding-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +const modernRequest = (method: string, params: Record = {}): JSONRPCRequest => + ({ + jsonrpc: '2.0', + id: 1, + method, + params: { ...params, _meta: ENVELOPE } + }) as JSONRPCRequest; + +function buildMcpServer(uriTemplate: string): McpServer { + const mcpServer = new McpServer({ name: 'literal-encoding-server', version: '1.0.0' }); + mcpServer.registerResource('docs', new ResourceTemplate(uriTemplate, { list: undefined }), {}, async (uri, { name }) => ({ + contents: [{ uri: uri.href, text: `contents of ${String(name)}` }] + })); + return mcpServer; +} + +async function modernBody(uriTemplate: string, method: string, params: Record = {}): Promise> { + const mcpServer = buildMcpServer(uriTemplate); + setNegotiatedProtocolVersion(mcpServer.server, MODERN_REVISION); + const response = await invoke(mcpServer, modernRequest(method, params), { classification: MODERN }); + return (await response.json()) as Record; +} + +describe('a resource template with a non-ASCII literal', () => { + it('is advertised with the template spelled as registered', async () => { + const body = (await modernBody('file:///docs/café/{name}', 'resources/templates/list')) as { + result: { resourceTemplates: { uriTemplate: string }[] }; + }; + expect(body.result.resourceTemplates[0]?.uriTemplate).toBe('file:///docs/café/{name}'); + }); + + it.each([ + ['pct-encoded', 'file:///docs/caf%C3%A9/a.txt'], + ['raw', 'file:///docs/café/a.txt'] + ])('is read when the client sends the %s URI', async (_form, uri) => { + const body = (await modernBody('file:///docs/café/{name}', 'resources/read', { uri })) as { + result?: { contents: { uri: string; text: string }[] }; + error?: unknown; + }; + expect(body.error).toBeUndefined(); + expect(body.result?.contents[0]).toMatchObject({ + uri: 'file:///docs/caf%C3%A9/a.txt', + text: 'contents of a.txt' + }); + }); + + it('is read when the literal carries a space', async () => { + const body = (await modernBody('file:///my docs/{name}', 'resources/read', { uri: 'file:///my%20docs/a.txt' })) as { + result?: { contents: { text: string }[] }; + error?: unknown; + }; + expect(body.error).toBeUndefined(); + expect(body.result?.contents[0]?.text).toBe('contents of a.txt'); + }); +}); From 8bc4f06372c768c5d6ee2b06a523e9ad01026837 Mon Sep 17 00:00:00 2001 From: LGUIUX <146765771+LGUIUX@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:24:05 -0400 Subject: [PATCH 2/3] fix(uri-template): keep the brackets of an IPv6 host literal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- .../core-internal/src/shared/uriTemplate.ts | 17 +++++++++++++++-- .../test/shared/uriTemplate.test.ts | 11 +++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/core-internal/src/shared/uriTemplate.ts b/packages/core-internal/src/shared/uriTemplate.ts index 5d0790a01e..464f2a72c2 100644 --- a/packages/core-internal/src/shared/uriTemplate.ts +++ b/packages/core-internal/src/shared/uriTemplate.ts @@ -43,10 +43,23 @@ export class UriTemplate { let result = ''; let last = 0; for (const match of text.matchAll(/%[0-9A-Fa-f]{2}/g)) { - result += encodeURI(text.slice(last, match.index)) + match[0]; + result += UriTemplate.encodeLiteralRun(text.slice(last, match.index)) + match[0]; last = match.index + match[0].length; } - return result + encodeURI(text.slice(last)); + return result + UriTemplate.encodeLiteralRun(text.slice(last)); + } + + /** + * Encodes one run of literal text that holds no `%XX` triplet. + * + * `encodeURI` escapes `[` and `]`, which RFC 3986 reserves for an IPv6 host + * literal — §3.1 leaves reserved characters to the template author, and + * `new URL('http://[::1]/x')` keeps them — so they are restored here. The + * restore runs per gap rather than over the whole literal, so a `%5B` the + * author wrote themselves still passes through as a triplet. + */ + private static encodeLiteralRun(text: string): string { + return encodeURI(text).replaceAll('%5B', '[').replaceAll('%5D', ']'); } private readonly template: string; private readonly parts: Array; diff --git a/packages/core-internal/test/shared/uriTemplate.test.ts b/packages/core-internal/test/shared/uriTemplate.test.ts index 42116e70a4..a4563ec8bc 100644 --- a/packages/core-internal/test/shared/uriTemplate.test.ts +++ b/packages/core-internal/test/shared/uriTemplate.test.ts @@ -97,8 +97,19 @@ describe('UriTemplate', () => { expect(new UriTemplate('http://x.test/p?q=1&r=2#{v}').expand({ v: 'x' })).toBe('http://x.test/p?q=1&r=2#x'); }); + it('should keep the brackets of an IPv6 host literal', () => { + // encodeURI escapes [ and ], but RFC 3986 reserves them for the host and + // `new URL()` keeps them, so the pattern has to keep them too. + const template = new UriTemplate('http://[::1]:8080/docs/{name}'); + expect(new URL('http://[::1]:8080/docs/a.txt').href).toBe('http://[::1]:8080/docs/a.txt'); + expect(template.expand({ name: 'a.txt' })).toBe('http://[::1]:8080/docs/a.txt'); + expect(template.match('http://[::1]:8080/docs/a.txt')).toEqual({ name: 'a.txt' }); + }); + it('should not encode an already-encoded literal twice', () => { expect(new UriTemplate('file:///docs/caf%C3%A9/{v}').expand({ v: 'a.txt' })).toBe('file:///docs/caf%C3%A9/a.txt'); + // a %5B the author wrote themselves stays a triplet + expect(new UriTemplate('file:///docs/%5Bx%5D/{v}').expand({ v: 'a.txt' })).toBe('file:///docs/%5Bx%5D/a.txt'); }); it('should match the pct-encoded form a URL round-trip produces', () => { From 37d91646798b807055ca23dc3a96b98ef012ccdb Mon Sep 17 00:00:00 2001 From: LGUIUX <146765771+LGUIUX@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:32:46 -0400 Subject: [PATCH 3/3] perf(uri-template): encode literals once, at construction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../core-internal/src/shared/uriTemplate.ts | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/core-internal/src/shared/uriTemplate.ts b/packages/core-internal/src/shared/uriTemplate.ts index 464f2a72c2..927480129e 100644 --- a/packages/core-internal/src/shared/uriTemplate.ts +++ b/packages/core-internal/src/shared/uriTemplate.ts @@ -35,9 +35,10 @@ export class UriTemplate { * pass through unchanged so an already-encoded literal is not encoded twice * (`encodeURI` alone would turn `caf%C3%A9` into `caf%25C3%25A9`). * - * Applied to both directions: `expand()` emits the encoded form and `match()` - * builds its pattern from it, so an expanded URI matches the template it came - * from — as does the pct-encoded URI a `new URL()` round-trip produces. + * Applied once in the constructor, so both directions read the same literal: + * `expand()` emits the encoded form and `match()` builds its pattern from it. + * An expanded URI therefore matches the template it came from — as does the + * pct-encoded URI a `new URL()` round-trip produces. */ private static encodeLiteral(text: string): string { let result = ''; @@ -71,7 +72,9 @@ export class UriTemplate { constructor(template: string) { UriTemplate.validateLength(template, MAX_TEMPLATE_LENGTH, 'Template'); this.template = template; - this.parts = this.parse(template); + // Literals are encoded once here rather than on every expand()/match(): + // they come from the template, so the result is the same every time. + this.parts = this.parse(template).map(part => (typeof part === 'string' ? UriTemplate.encodeLiteral(part) : part)); } toString(): string { @@ -212,7 +215,7 @@ export class UriTemplate { for (const part of this.parts) { if (typeof part === 'string') { - result += UriTemplate.encodeLiteral(part); + result += part; continue; } @@ -296,9 +299,10 @@ export class UriTemplate { for (const part of this.parts) { if (typeof part === 'string') { - // Encoded so the pattern lines up with what expand() emits: the - // server matches against a `new URL()` round-trip (RFC 6570 §3.1). - pattern += this.escapeRegExp(UriTemplate.encodeLiteral(part)); + // Already encoded in the constructor, so the pattern lines up with + // what expand() emits and with the URI the server resolves through + // `new URL()` (RFC 6570 §3.1). + pattern += this.escapeRegExp(part); } else { const patterns = this.partToRegExp(part); for (const { pattern: partPattern, name } of patterns) {