From f30efc5a047d08af61d7dbd9de0795bae2f79ae2 Mon Sep 17 00:00:00 2001 From: MokuLakshithReddy Date: Mon, 12 Oct 2026 00:56:38 +0530 Subject: [PATCH 1/3] fix(core-internal): support RFC 6570 prefix modifiers in UriTemplate (#3000) --- .changeset/uri-template-prefix-modifiers.md | 5 + .../core-internal/src/shared/uriTemplate.ts | 101 ++++++++++++------ .../test/shared/uriTemplate.test.ts | 96 +++++++++++++++++ 3 files changed, 169 insertions(+), 33 deletions(-) create mode 100644 .changeset/uri-template-prefix-modifiers.md diff --git a/.changeset/uri-template-prefix-modifiers.md b/.changeset/uri-template-prefix-modifiers.md new file mode 100644 index 0000000000..5b2f5e0dc2 --- /dev/null +++ b/.changeset/uri-template-prefix-modifiers.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/core-internal': patch +--- + +Support RFC 6570 prefix modifiers (`:N`) in `UriTemplate`: retain clean variable names in `variableNames` and `match()`, and apply prefix truncation to Unicode code points before percent-encoding during expansion. diff --git a/packages/core-internal/src/shared/uriTemplate.ts b/packages/core-internal/src/shared/uriTemplate.ts index 5ffe213acd..f8594a5ed5 100644 --- a/packages/core-internal/src/shared/uriTemplate.ts +++ b/packages/core-internal/src/shared/uriTemplate.ts @@ -7,6 +7,20 @@ const MAX_VARIABLE_LENGTH = 1_000_000; // 1MB const MAX_TEMPLATE_EXPRESSIONS = 10_000; const MAX_REGEX_LENGTH = 1_000_000; // 1MB +interface VarSpec { + name: string; + exploded: boolean; + prefix?: number; +} + +interface TemplateExpressionPart { + name: string; + operator: string; + names: string[]; + exploded: boolean; + vars: VarSpec[]; +} + export class UriTemplate { /** * Returns true if the given string contains any URI template expressions. @@ -25,7 +39,7 @@ export class UriTemplate { } } private readonly template: string; - private readonly parts: Array; + private readonly parts: Array; get variableNames(): string[] { return this.parts.flatMap(part => (typeof part === 'string' ? [] : part.names)); @@ -41,8 +55,8 @@ export class UriTemplate { return this.template; } - private parse(template: string): Array { - const parts: Array = []; + private parse(template: string): Array { + const parts: Array = []; let currentText = ''; let i = 0; let expressionCount = 0; @@ -64,7 +78,8 @@ export class UriTemplate { const expr = template.slice(i + 1, end); const operator = this.getOperator(expr); const exploded = expr.includes('*'); - const names = this.getNames(expr); + const vars = this.getVarSpecs(expr); + const names = vars.map(v => v.name); const name = names[0]!; // Validate variable name length @@ -72,7 +87,7 @@ export class UriTemplate { UriTemplate.validateLength(name, MAX_VARIABLE_LENGTH, 'Variable name'); } - parts.push({ name, operator, names, exploded }); + parts.push({ name, operator, names, exploded, vars }); i = end + 1; } else { currentText += template[i]; @@ -92,13 +107,38 @@ export class UriTemplate { return operators.find(op => expr.startsWith(op)) || ''; } - private getNames(expr: string): string[] { + private parseVarSpec(spec: string): VarSpec { + const trimmed = spec.trim(); + const exploded = trimmed.endsWith('*'); + const withoutExplode = exploded ? trimmed.slice(0, -1) : trimmed; + const colonIndex = withoutExplode.indexOf(':'); + if (colonIndex !== -1) { + const name = withoutExplode.slice(0, colonIndex).trim(); + const prefixStr = withoutExplode.slice(colonIndex + 1).trim(); + const prefix = /^\d+$/.test(prefixStr) ? Number.parseInt(prefixStr, 10) : undefined; + return { name, exploded, prefix: prefix !== undefined && prefix > 0 ? prefix : undefined }; + } + return { name: withoutExplode.trim(), exploded }; + } + + private getVarSpecs(expr: string): VarSpec[] { const operator = this.getOperator(expr); return expr .slice(operator.length) .split(',') - .map(name => name.replace('*', '').trim()) - .filter(name => name.length > 0); + .map(spec => this.parseVarSpec(spec)) + .filter(v => v.name.length > 0); + } + + private getNames(expr: string): string[] { + return this.getVarSpecs(expr).map(v => v.name); + } + + private applyPrefix(value: string, prefix?: number): string { + if (prefix === undefined || prefix <= 0) { + return value; + } + return [...value].slice(0, prefix).join(''); } private encodeValue(value: string, operator: string): string { @@ -109,24 +149,16 @@ export class UriTemplate { return encodeURIComponent(value); } - private expandPart( - part: { - name: string; - operator: string; - names: string[]; - exploded: boolean; - }, - variables: Variables - ): string { + private expandPart(part: TemplateExpressionPart, variables: Variables): string { if (part.operator === '?' || part.operator === '&') { - const pairs = part.names - .map(name => { - const value = variables[name]; + const pairs = part.vars + .map(v => { + const value = variables[v.name]; if (value === undefined) return ''; const encoded = Array.isArray(value) - ? value.map(v => this.encodeValue(v, part.operator)).join(',') - : this.encodeValue(value.toString(), part.operator); - return `${name}=${encoded}`; + ? value.map(val => this.encodeValue(val, part.operator)).join(',') + : this.encodeValue(this.applyPrefix(value.toString(), v.prefix), part.operator); + return `${v.name}=${encoded}`; }) .filter(pair => pair.length > 0); @@ -135,16 +167,24 @@ export class UriTemplate { return separator + pairs.join('&'); } - if (part.names.length > 1) { - const values = part.names.map(name => variables[name]).filter(v => v !== undefined); + if (part.vars.length > 1) { + const values = part.vars + .map(v => { + const value = variables[v.name]; + if (value === undefined) return; + if (Array.isArray(value)) return value[0]; + return this.applyPrefix(value.toString(), v.prefix); + }) + .filter(v => v !== undefined); if (values.length === 0) return ''; - return values.map(v => (Array.isArray(v) ? v[0] : v)).join(','); + return values.join(','); } const value = variables[part.name]; if (value === undefined) return ''; - const values = Array.isArray(value) ? value : [value]; + const varSpec = part.vars[0]; + const values = Array.isArray(value) ? value : [this.applyPrefix(value.toString(), varSpec?.prefix)]; const encoded = values.map(v => this.encodeValue(v, part.operator)); switch (part.operator) { @@ -197,12 +237,7 @@ export class UriTemplate { return str.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); } - private partToRegExp(part: { - name: string; - operator: string; - names: string[]; - exploded: boolean; - }): Array<{ pattern: string; name: string }> { + private partToRegExp(part: TemplateExpressionPart): Array<{ pattern: string; name: string }> { const patterns: Array<{ pattern: string; name: string }> = []; // Validate variable name length for matching diff --git a/packages/core-internal/test/shared/uriTemplate.test.ts b/packages/core-internal/test/shared/uriTemplate.test.ts index bfc3237872..9e08da4055 100644 --- a/packages/core-internal/test/shared/uriTemplate.test.ts +++ b/packages/core-internal/test/shared/uriTemplate.test.ts @@ -311,4 +311,100 @@ describe('UriTemplate', () => { expect(elapsed).toBeLessThan(100); }); }); + + describe('RFC 6570 prefix modifier', () => { + it('should report variableNames without the prefix modifier', () => { + const template = new UriTemplate('resource:///{name:3}'); + expect(template.variableNames).toEqual(['name']); + }); + + it('should truncate scalar values to prefix length for simple string expansion', () => { + const template = new UriTemplate('{name:3}'); + expect(template.expand({ name: 'abcdef' })).toBe('abc'); + expect(template.variableNames).toEqual(['name']); + }); + + it('should expand prefix in URI path context', () => { + const template = new UriTemplate('resource:///{name:3}'); + expect(template.expand({ name: 'abcdef' })).toBe('resource:///abc'); + }); + + it('should apply prefix to Unicode code points before percent-encoding', () => { + const template = new UriTemplate('{name:2}'); + expect(template.expand({ name: '你好世界' })).toBe(encodeURIComponent('你好')); + }); + + it('should handle supplementary Unicode characters (surrogate pairs)', () => { + const template = new UriTemplate('{emoji:2}'); + expect(template.expand({ emoji: '👋🌍🎉' })).toBe(encodeURIComponent('👋🌍')); + }); + + it('should use the entire string when prefix is longer than value', () => { + const template = new UriTemplate('{name:30}'); + expect(template.expand({ name: 'abcdef' })).toBe('abcdef'); + }); + + it('should return empty string when variable is missing', () => { + const template = new UriTemplate('{name:3}'); + expect(template.expand({})).toBe(''); + }); + + it('should support prefix modifiers with reserved expansion (+ operator)', () => { + const template = new UriTemplate('{+path:4}/here'); + expect(template.expand({ path: '/foo/bar' })).toBe('/foo/here'); + expect(template.variableNames).toEqual(['path']); + }); + + it('should support prefix modifiers with fragment operator (#)', () => { + const template = new UriTemplate('X{#var:2}'); + expect(template.expand({ var: '/test' })).toBe('X#/t'); + expect(template.variableNames).toEqual(['var']); + }); + + it('should support prefix modifiers with label operator (.)', () => { + const template = new UriTemplate('X{.var:2}'); + expect(template.expand({ var: 'test' })).toBe('X.te'); + expect(template.variableNames).toEqual(['var']); + }); + + it('should support prefix modifiers with path operator (/)', () => { + const template = new UriTemplate('X{/var:2}'); + expect(template.expand({ var: 'test' })).toBe('X/te'); + expect(template.variableNames).toEqual(['var']); + }); + + it('should support prefix modifiers with query operator (?)', () => { + const template = new UriTemplate('X{?var:2}'); + expect(template.expand({ var: 'test' })).toBe('X?var=te'); + expect(template.variableNames).toEqual(['var']); + }); + + it('should support prefix modifiers with form continuation operator (&)', () => { + const template = new UriTemplate('X{&var:2}'); + expect(template.expand({ var: 'test' })).toBe('X&var=te'); + expect(template.variableNames).toEqual(['var']); + }); + + it('should ignore prefix modifier on composite (array) values per RFC 6570', () => { + const template = new UriTemplate('{list:2}'); + expect(template.expand({ list: ['red', 'green'] })).toBe('red,green'); + expect(template.variableNames).toEqual(['list']); + }); + + it('should handle multiple variables with prefix modifiers', () => { + const template = new UriTemplate('{x:2,y:3}'); + expect(template.expand({ x: '1024', y: '768' })).toBe('10,768'); + expect(template.variableNames).toEqual(['x', 'y']); + }); + + it('should match URIs and extract variables for templates with prefix modifiers', () => { + const template = new UriTemplate('resource:///{name:3}'); + expect(template.match('resource:///abc')).toEqual({ name: 'abc' }); + }); + + it('should match query parameters for templates with prefix modifiers', () => { + const template = new UriTemplate('/search{?q:5}'); + expect(template.match('/search?q=hello')).toEqual({ q: 'hello' }); + }); + }); }); From 2e8e0c95bc8edd5eaf60a9fc15ca614f44a8d2ec Mon Sep 17 00:00:00 2001 From: MokuLakshithReddy Date: Tue, 13 Oct 2026 11:55:50 +0530 Subject: [PATCH 2/3] fix(core-internal): enforce RFC 6570 prefix max-length and modifier mutual exclusivity --- .../core-internal/src/shared/uriTemplate.ts | 34 ++++++++++++++----- .../test/shared/uriTemplate.test.ts | 32 +++++++++++++++++ 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/packages/core-internal/src/shared/uriTemplate.ts b/packages/core-internal/src/shared/uriTemplate.ts index f8594a5ed5..5255d8aa40 100644 --- a/packages/core-internal/src/shared/uriTemplate.ts +++ b/packages/core-internal/src/shared/uriTemplate.ts @@ -109,16 +109,34 @@ export class UriTemplate { private parseVarSpec(spec: string): VarSpec { const trimmed = spec.trim(); - const exploded = trimmed.endsWith('*'); - const withoutExplode = exploded ? trimmed.slice(0, -1) : trimmed; - const colonIndex = withoutExplode.indexOf(':'); + + // Prefix and explode modifiers are mutually exclusive per RFC 6570 + if (trimmed.includes(':') && trimmed.includes('*')) { + return { name: trimmed.replaceAll('*', '').trim(), exploded: false }; + } + + if (trimmed.endsWith('*')) { + const name = trimmed.slice(0, -1).trim(); + return { name, exploded: true }; + } + + const colonIndex = trimmed.indexOf(':'); if (colonIndex !== -1) { - const name = withoutExplode.slice(0, colonIndex).trim(); - const prefixStr = withoutExplode.slice(colonIndex + 1).trim(); - const prefix = /^\d+$/.test(prefixStr) ? Number.parseInt(prefixStr, 10) : undefined; - return { name, exploded, prefix: prefix !== undefined && prefix > 0 ? prefix : undefined }; + const name = trimmed.slice(0, colonIndex).trim(); + const prefixStr = trimmed.slice(colonIndex + 1).trim(); + // RFC 6570 defines max-length as 1*4DIGIT (non-zero positive integer < 10000) + if (/^[1-9]\d{0,3}$/.test(prefixStr)) { + return { + name, + exploded: false, + prefix: Number.parseInt(prefixStr, 10) + }; + } + // Malformed prefix (e.g. :0, :10000, :abc) - preserve unresolved behavior + return { name: trimmed, exploded: false }; } - return { name: withoutExplode.trim(), exploded }; + + return { name: trimmed, exploded: false }; } private getVarSpecs(expr: string): VarSpec[] { diff --git a/packages/core-internal/test/shared/uriTemplate.test.ts b/packages/core-internal/test/shared/uriTemplate.test.ts index 9e08da4055..93d5135170 100644 --- a/packages/core-internal/test/shared/uriTemplate.test.ts +++ b/packages/core-internal/test/shared/uriTemplate.test.ts @@ -406,5 +406,37 @@ describe('UriTemplate', () => { const template = new UriTemplate('/search{?q:5}'); expect(template.match('/search?q=hello')).toEqual({ q: 'hello' }); }); + + describe('malformed and combined modifier boundary cases', () => { + it('should not resolve prefix of :0 and preserve unresolved behavior', () => { + const template = new UriTemplate('{name:0}'); + expect(template.variableNames).toEqual(['name:0']); + expect(template.expand({ name: 'abcdef' })).toBe(''); + }); + + it('should not resolve prefix with five or more digits and preserve unresolved behavior', () => { + const template = new UriTemplate('{name:10000}'); + expect(template.variableNames).toEqual(['name:10000']); + expect(template.expand({ name: 'abcdef' })).toBe(''); + }); + + it('should not resolve non-digit prefix and preserve unresolved behavior', () => { + const template = new UriTemplate('{name:abc}'); + expect(template.variableNames).toEqual(['name:abc']); + expect(template.expand({ name: 'abcdef' })).toBe(''); + }); + + it('should reject combined prefix and explode modifiers :N* and preserve unresolved behavior', () => { + const template = new UriTemplate('{name:3*}'); + expect(template.variableNames).toEqual(['name:3']); + expect(template.expand({ name: 'abcdef' })).toBe(''); + }); + + it('should accept valid upper boundary prefix length (up to 4 digits, e.g. 9999)', () => { + const template = new UriTemplate('{name:9999}'); + expect(template.variableNames).toEqual(['name']); + expect(template.expand({ name: 'abcdef' })).toBe('abcdef'); + }); + }); }); }); From 7f1ca4c76edf9ddf9b8d8062607a5751fa1dfc41 Mon Sep 17 00:00:00 2001 From: MokuLakshithReddy Date: Wed, 14 Oct 2026 16:23:21 +0530 Subject: [PATCH 3/3] fix(core-internal): encode multi-variable values and join with operator separators --- .../core-internal/src/shared/uriTemplate.ts | 34 ++++++++----------- .../test/shared/uriTemplate.test.ts | 27 +++++++++++++-- 2 files changed, 38 insertions(+), 23 deletions(-) diff --git a/packages/core-internal/src/shared/uriTemplate.ts b/packages/core-internal/src/shared/uriTemplate.ts index 5255d8aa40..a9e322b660 100644 --- a/packages/core-internal/src/shared/uriTemplate.ts +++ b/packages/core-internal/src/shared/uriTemplate.ts @@ -185,30 +185,24 @@ export class UriTemplate { return separator + pairs.join('&'); } - if (part.vars.length > 1) { - const values = part.vars - .map(v => { - const value = variables[v.name]; - if (value === undefined) return; - if (Array.isArray(value)) return value[0]; - return this.applyPrefix(value.toString(), v.prefix); - }) - .filter(v => v !== undefined); - if (values.length === 0) return ''; - return values.join(','); + const encoded: string[] = []; + for (const v of part.vars) { + const value = variables[v.name]; + if (value === undefined) continue; + if (Array.isArray(value)) { + for (const item of value) { + encoded.push(this.encodeValue(item, part.operator)); + } + } else { + const str = this.applyPrefix(value.toString(), v.prefix); + encoded.push(this.encodeValue(str, part.operator)); + } } - const value = variables[part.name]; - if (value === undefined) return ''; - - const varSpec = part.vars[0]; - const values = Array.isArray(value) ? value : [this.applyPrefix(value.toString(), varSpec?.prefix)]; - const encoded = values.map(v => this.encodeValue(v, part.operator)); + if (encoded.length === 0) return ''; switch (part.operator) { - case '': { - return encoded.join(','); - } + case '': case '+': { return encoded.join(','); } diff --git a/packages/core-internal/test/shared/uriTemplate.test.ts b/packages/core-internal/test/shared/uriTemplate.test.ts index 93d5135170..ebd1ac98e6 100644 --- a/packages/core-internal/test/shared/uriTemplate.test.ts +++ b/packages/core-internal/test/shared/uriTemplate.test.ts @@ -391,17 +391,38 @@ describe('UriTemplate', () => { expect(template.variableNames).toEqual(['list']); }); - it('should handle multiple variables with prefix modifiers', () => { - const template = new UriTemplate('{x:2,y:3}'); - expect(template.expand({ x: '1024', y: '768' })).toBe('10,768'); + it('should handle multiple variables with prefix modifiers and encode values', () => { + const template = new UriTemplate('{x,y:2}'); + expect(template.expand({ x: '1024', y: 'é ü' })).toBe('1024,%C3%A9%20'); expect(template.variableNames).toEqual(['x', 'y']); }); + it('should join multiple variables using the operator separator', () => { + const pathTemplate = new UriTemplate('{/x,y}'); + expect(pathTemplate.expand({ x: '1024', y: '768' })).toBe('/1024/768'); + + const labelTemplate = new UriTemplate('{.x,y}'); + expect(labelTemplate.expand({ x: '1024', y: '768' })).toBe('.1024.768'); + + const fragTemplate = new UriTemplate('{#x,y}'); + expect(fragTemplate.expand({ x: '1024', y: '768' })).toBe('#1024,768'); + + const partialTemplate = new UriTemplate('{/x,y}'); + expect(partialTemplate.expand({ x: '1024' })).toBe('/1024'); + }); + it('should match URIs and extract variables for templates with prefix modifiers', () => { const template = new UriTemplate('resource:///{name:3}'); expect(template.match('resource:///abc')).toEqual({ name: 'abc' }); }); + it('pins that match() extracts the segment value without enforcing prefix truncation', () => { + const template = new UriTemplate('/users/{id:3}'); + // Prefix modifiers apply during expansion (RFC 6570); match() extracts the matched segment + expect(template.match('/users/abc')).toEqual({ id: 'abc' }); + expect(template.match('/users/abcdef')).toEqual({ id: 'abcdef' }); + }); + it('should match query parameters for templates with prefix modifiers', () => { const template = new UriTemplate('/search{?q:5}'); expect(template.match('/search?q=hello')).toEqual({ q: 'hello' });