Skip to content
Draft
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
8 changes: 8 additions & 0 deletions .changeset/uri-template-optional-segments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@modelcontextprotocol/core-internal': patch
---

`UriTemplate` now handles optional segments, so a `ResourceTemplate` with an optional variable resolves whether or not the segment is present (#677).

- `{name?}`: the trailing `?` is no longer kept as part of the variable name. `variableNames` reports `name`, `expand()` uses the `name` key and omits the segment together with its leading `/` when the value is absent, and `match()` accepts the URI with or without that segment.
- `{/name}` and `{.name}`: `match()` now accepts the URI that `expand()` produces when the variable is undefined, instead of returning `null`.
78 changes: 47 additions & 31 deletions packages/core-internal/src/shared/uriTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ const MAX_VARIABLE_LENGTH = 1_000_000; // 1MB
const MAX_TEMPLATE_EXPRESSIONS = 10_000;
const MAX_REGEX_LENGTH = 1_000_000; // 1MB

type TemplatePart = {
name: string;
operator: string;
names: string[];
exploded: boolean;
/** Set by a trailing `?` on the variable, e.g. `{name?}`: the segment may be absent. */
optional: boolean;
};

// Operators whose expansion is empty when the variable is undefined, so a match must tolerate the segment being absent.
const PREFIXED_SEGMENT_OPERATORS = new Set(['/', '.']);

export class UriTemplate {
/**
* Returns true if the given string contains any URI template expressions.
Expand All @@ -25,7 +37,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 | TemplatePart>;

get variableNames(): string[] {
return this.parts.flatMap(part => (typeof part === 'string' ? [] : part.names));
Expand All @@ -41,8 +53,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 | TemplatePart> {
const parts: Array<string | TemplatePart> = [];
let currentText = '';
let i = 0;
let expressionCount = 0;
Expand All @@ -64,6 +76,7 @@ export class UriTemplate {
const expr = template.slice(i + 1, end);
const operator = this.getOperator(expr);
const exploded = expr.includes('*');
const optional = expr.trimEnd().endsWith('?');
const names = this.getNames(expr);
const name = names[0]!;

Expand All @@ -72,7 +85,7 @@ export class UriTemplate {
UriTemplate.validateLength(name, MAX_VARIABLE_LENGTH, 'Variable name');
}

parts.push({ name, operator, names, exploded });
parts.push({ name, operator, names, exploded, optional });
i = end + 1;
} else {
currentText += template[i];
Expand All @@ -97,7 +110,7 @@ export class UriTemplate {
return expr
.slice(operator.length)
.split(',')
.map(name => name.replace('*', '').trim())
.map(name => name.replace('*', '').trim().replace(/\?$/, ''))
.filter(name => name.length > 0);
}

Expand All @@ -109,15 +122,7 @@ export class UriTemplate {
return encodeURIComponent(value);
}

private expandPart(
part: {
name: string;
operator: string;
names: string[];
exploded: boolean;
},
variables: Variables
): string {
private expandPart(part: TemplatePart, variables: Variables): string {
if (part.operator === '?' || part.operator === '&') {
const pairs = part.names
.map(name => {
Expand Down Expand Up @@ -173,14 +178,18 @@ export class UriTemplate {
let result = '';
let hasQueryParam = false;

for (const part of this.parts) {
for (const [index, part] of this.parts.entries()) {
if (typeof part === 'string') {
result += part;
continue;
}

const expanded = this.expandPart(part, variables);
if (!expanded) continue;
if (!expanded) {
// An absent `{name?}` takes its leading `/` with it: `a/{b?}` expands to `a`, not `a/`.
if (this.optionalSeparator(this.parts[index - 1], part)) result = result.slice(0, -1);
continue;
}

// Convert ? to & if we already have a query parameter
result += (part.operator === '?' || part.operator === '&') && hasQueryParam ? expanded.replace('?', '&') : expanded;
Expand All @@ -193,16 +202,17 @@ export class UriTemplate {
return result;
}

/** Returns `/` when `literal` ends with the separator that belongs to the optional `{name?}` part following it. */
private optionalSeparator(literal: string | TemplatePart | undefined, part: string | TemplatePart | undefined): string {
if (typeof literal !== 'string' || part === undefined || typeof part === 'string') return '';
return part.optional && literal.endsWith('/') ? '/' : '';
}

private escapeRegExp(str: string): string {
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: TemplatePart): Array<{ pattern: string; name: string }> {
const patterns: Array<{ pattern: string; name: string }> = [];

// Validate variable name length for matching
Expand Down Expand Up @@ -257,15 +267,19 @@ export class UriTemplate {
let pattern = '^';
const names: Array<{ name: string; exploded: boolean }> = [];

for (const part of this.parts) {
for (const [index, part] of this.parts.entries()) {
if (typeof part === 'string') {
pattern += this.escapeRegExp(part);
} else {
const patterns = this.partToRegExp(part);
for (const { pattern: partPattern, name } of patterns) {
pattern += partPattern;
names.push({ name, exploded: part.exploded });
}
// A separator owned by a following `{name?}` is emitted inside that part's optional group.
const literal = this.optionalSeparator(part, this.parts[index + 1]) ? part.slice(0, -1) : part;
pattern += this.escapeRegExp(literal);
continue;
}

const separator = this.optionalSeparator(this.parts[index - 1], part);
const isOptional = part.optional || PREFIXED_SEGMENT_OPERATORS.has(part.operator);
for (const { pattern: partPattern, name } of this.partToRegExp(part)) {
pattern += isOptional ? `(?:${separator}${partPattern})?` : partPattern;
names.push({ name, exploded: part.exploded });
}
}

Expand All @@ -279,7 +293,9 @@ export class UriTemplate {
const result: Variables = {};
for (const [i, name_] of names.entries()) {
const { name, exploded } = name_!;
const value = match[i + 1]!;
const value = match[i + 1];
// Only an optional group can be left unmatched; an absent segment yields no key.
if (value === undefined) continue;
const cleanName = name.replace('*', '');

result[cleanName] = exploded && value.includes(',') ? value.split(',') : value;
Expand Down
46 changes: 46 additions & 0 deletions packages/core-internal/test/shared/uriTemplate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,52 @@ describe('UriTemplate', () => {
});
});

describe('optional variables', () => {
it('should strip the trailing ? from optional variable names', () => {
const template = new UriTemplate('scheme://path/{required}/{optional?}');
expect(template.variableNames).toEqual(['required', 'optional']);
});

it('should expand an optional variable when it is provided', () => {
const template = new UriTemplate('scheme://path/{required}/{optional?}');
expect(template.expand({ required: 'foo', optional: 'bar' })).toBe('scheme://path/foo/bar');
});

it('should omit the optional segment and its separator when the variable is absent', () => {
const template = new UriTemplate('scheme://path/{required}/{optional?}');
expect(template.expand({ required: 'foo' })).toBe('scheme://path/foo');
});

it('should match a URI that includes the optional segment', () => {
const template = new UriTemplate('scheme://path/{required}/{optional?}');
expect(template.match('scheme://path/foo/bar')).toEqual({ required: 'foo', optional: 'bar' });
});

it('should match a URI that omits the optional segment', () => {
const template = new UriTemplate('scheme://path/{required}/{optional?}');
expect(template.match('scheme://path/foo')).toEqual({ required: 'foo' });
});

it('should still reject a URI missing a required segment', () => {
const template = new UriTemplate('scheme://path/{required}/{optional?}');
expect(template.match('scheme://path/')).toBeNull();
});

it('should match the expansion of an absent {/var} path segment', () => {
const template = new UriTemplate('scheme://path/{required}{/optional}');
const uri = template.expand({ required: 'foo' });
expect(uri).toBe('scheme://path/foo');
expect(template.match(uri)).toEqual({ required: 'foo' });
expect(template.match('scheme://path/foo/bar')).toEqual({ required: 'foo', optional: 'bar' });
});

it('should match the expansion of an absent {.var} label', () => {
const template = new UriTemplate('file{.ext}');
expect(template.match(template.expand({}))).toEqual({});
expect(template.match('file.txt')).toEqual({ ext: 'txt' });
});
});

describe('edge cases', () => {
it('should handle empty variables', () => {
const template = new UriTemplate('{empty}');
Expand Down
Loading