Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/uri-template-prefix-modifiers.md
Original file line number Diff line number Diff line change
@@ -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.
129 changes: 88 additions & 41 deletions packages/core-internal/src/shared/uriTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -25,7 +39,7 @@ export class UriTemplate {
}
}
private readonly template: string;
private readonly parts: Array<string | { name: string; operator: string; names: string[]; exploded: boolean }>;
private readonly parts: Array<string | TemplateExpressionPart>;

get variableNames(): string[] {
return this.parts.flatMap(part => (typeof part === 'string' ? [] : part.names));
Expand All @@ -41,8 +55,8 @@ export class UriTemplate {
return this.template;
}

private parse(template: string): Array<string | { name: string; operator: string; names: string[]; exploded: boolean }> {
const parts: Array<string | { name: string; operator: string; names: string[]; exploded: boolean }> = [];
private parse(template: string): Array<string | TemplateExpressionPart> {
const parts: Array<string | TemplateExpressionPart> = [];
let currentText = '';
let i = 0;
let expressionCount = 0;
Expand All @@ -64,15 +78,16 @@ 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
for (const name of names) {
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];
Expand All @@ -92,13 +107,56 @@ export class UriTemplate {
return operators.find(op => expr.startsWith(op)) || '';
}

private getNames(expr: string): string[] {
private parseVarSpec(spec: string): VarSpec {
const trimmed = spec.trim();

// 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 = 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: trimmed, exploded: false };
}

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 {
Expand All @@ -109,24 +167,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);

Expand All @@ -135,22 +185,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 (values.length === 0) return '';
return values.map(v => (Array.isArray(v) ? v[0] : v)).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 values = Array.isArray(value) ? value : [value];
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(',');
}
Expand Down Expand Up @@ -197,12 +249,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 }> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

match() doesn't use the prefix at all, so /users/{id:3} matches /users/abcdef and returns id: 'abcdef', a URI this template could never expand to. Is that intended (prefix only affects expansion), or should the pattern be bounded for prefix vars? Either way a test pinning the behavior might help, since resource templates use match() to route reads.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Prefix modifiers in RFC 6570 define variable expansion truncation. Matching in this SDK extracts the segment value without lossy truncation. I have added an explicit regression test to pin this contract (/users/{id:3} extracts { id: "abc" } and { id: "abcdef" }).

const patterns: Array<{ pattern: string; name: string }> = [];

// Validate variable name length for matching
Expand Down
149 changes: 149 additions & 0 deletions packages/core-internal/test/shared/uriTemplate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,4 +311,153 @@ 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 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' });
});

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');
});
});
});
});
Loading