From 1e1606328a45a3c6f8f4a30542c8db3fa45c7ecd Mon Sep 17 00:00:00 2001 From: errmakov Date: Thu, 17 Sep 2026 11:59:31 +0200 Subject: [PATCH] fix: support optional {name?} segments in UriTemplate A trailing ? on a template variable was kept as part of its name, so expand() dropped the value, match() returned the key 'name?', and a URI without the optional segment did not match at all. Strip the marker, omit the segment and its leading / when the value is absent, and let match() accept the URI either way. {/name} and {.name} had the same gap in match(): the URI expand() produces for an undefined variable returned null. v1.x backport of the main-branch fix. Fixes #677 Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/uri-template-optional-segments.md | 8 +++ src/shared/uriTemplate.ts | 76 ++++++++++++-------- test/shared/uriTemplate.test.ts | 46 ++++++++++++ 3 files changed, 100 insertions(+), 30 deletions(-) create mode 100644 .changeset/uri-template-optional-segments.md diff --git a/.changeset/uri-template-optional-segments.md b/.changeset/uri-template-optional-segments.md new file mode 100644 index 0000000000..dd96ae6eb7 --- /dev/null +++ b/.changeset/uri-template-optional-segments.md @@ -0,0 +1,8 @@ +--- +'@modelcontextprotocol/sdk': 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`. diff --git a/src/shared/uriTemplate.ts b/src/shared/uriTemplate.ts index a47a64c972..5be970e339 100644 --- a/src/shared/uriTemplate.ts +++ b/src/shared/uriTemplate.ts @@ -7,6 +7,18 @@ const MAX_VARIABLE_LENGTH = 1000000; // 1MB const MAX_TEMPLATE_EXPRESSIONS = 10000; const MAX_REGEX_LENGTH = 1000000; // 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. @@ -25,7 +37,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 +53,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,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]; @@ -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]; @@ -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); } @@ -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 => { @@ -167,14 +172,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 if ((part.operator === '?' || part.operator === '&') && hasQueryParam) { @@ -191,16 +200,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.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } - 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 @@ -250,15 +260,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 }); } } @@ -273,6 +287,8 @@ export class UriTemplate { for (let i = 0; i < names.length; i++) { const { name, exploded } = names[i]; 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('*', ''); if (exploded && value.includes(',')) { diff --git a/test/shared/uriTemplate.test.ts b/test/shared/uriTemplate.test.ts index 5bd54d2cfe..bbc0e33393 100644 --- a/test/shared/uriTemplate.test.ts +++ b/test/shared/uriTemplate.test.ts @@ -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}');