From 4202930fbb6ccd5c9b81d5a6b03eb4f30f83cadb Mon Sep 17 00:00:00 2001 From: Christian Sidak Date: Thu, 16 Apr 2026 21:41:18 -0700 Subject: [PATCH] fix: make UriTemplate.match() handle optional/out-of-order query params per RFC 6570 Query parameter templates ({?param1,param2}) now correctly match URIs where some or all query params are missing, and where params appear in any order. Previously, all declared params had to be present in the exact template-declared order. Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/fix-uri-template-optional-query.md | 5 ++ packages/core/src/shared/uriTemplate.ts | 39 ++++++++--- packages/core/test/shared/uriTemplate.test.ts | 64 +++++++++++++++++++ 3 files changed, 99 insertions(+), 9 deletions(-) create mode 100644 .changeset/fix-uri-template-optional-query.md diff --git a/.changeset/fix-uri-template-optional-query.md b/.changeset/fix-uri-template-optional-query.md new file mode 100644 index 000000000..cd86ff552 --- /dev/null +++ b/.changeset/fix-uri-template-optional-query.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/core': patch +--- + +Fix UriTemplate.match() to handle optional and out-of-order query parameters per RFC 6570. Templates like `{?param1,param2}` now correctly match URIs with no query params, a subset of params, or params in any order. diff --git a/packages/core/src/shared/uriTemplate.ts b/packages/core/src/shared/uriTemplate.ts index 5ffe213ac..cf6b6d281 100644 --- a/packages/core/src/shared/uriTemplate.ts +++ b/packages/core/src/shared/uriTemplate.ts @@ -210,15 +210,8 @@ export class UriTemplate { UriTemplate.validateLength(name, MAX_VARIABLE_LENGTH, 'Variable name'); } + // ?/& operators are handled directly in match() for order-independent, optional matching if (part.operator === '?' || part.operator === '&') { - for (let i = 0; i < part.names.length; i++) { - const name = part.names[i]!; - const prefix = i === 0 ? '\\' + part.operator : '&'; - patterns.push({ - pattern: prefix + this.escapeRegExp(name) + '=([^&]+)', - name - }); - } return patterns; } @@ -227,7 +220,8 @@ export class UriTemplate { switch (part.operator) { case '': { - pattern = part.exploded ? '([^/,]+(?:,[^/,]+)*)' : '([^/,]+)'; + // Exclude ?/# so path vars don't consume the query/fragment delimiter + pattern = part.exploded ? '([^/?#,]+(?:,[^/?#,]+)*)' : '([^/?#,]+)'; break; } case '+': @@ -256,10 +250,19 @@ export class UriTemplate { UriTemplate.validateLength(uri, MAX_TEMPLATE_LENGTH, 'URI'); let pattern = '^'; const names: Array<{ name: string; exploded: boolean }> = []; + const queryParamNames: string[] = []; for (const part of this.parts) { if (typeof part === 'string') { pattern += this.escapeRegExp(part); + } else if (part.operator === '?' || part.operator === '&') { + // Collect query param names for order-independent parsing below + for (const name of part.names) { + UriTemplate.validateLength(name, MAX_VARIABLE_LENGTH, 'Variable name'); + queryParamNames.push(name); + } + // Allow an optional query string (everything up to # or end) + pattern += '(?:[?&][^#]*)?'; } else { const patterns = this.partToRegExp(part); for (const { pattern: partPattern, name } of patterns) { @@ -285,6 +288,24 @@ export class UriTemplate { result[cleanName] = exploded && value.includes(',') ? value.split(',') : value; } + // Parse query params for ?/& template variables -- order-independent, all optional + if (queryParamNames.length > 0) { + const qIdx = uri.indexOf('?'); + if (qIdx !== -1) { + const queryString = uri.slice(qIdx + 1).split('#')[0]!; + for (const pair of queryString.split('&')) { + const eqIdx = pair.indexOf('='); + if (eqIdx !== -1) { + const key = pair.slice(0, eqIdx); + const val = pair.slice(eqIdx + 1); + if (queryParamNames.includes(key)) { + result[key] = val; + } + } + } + } + } + return result; } } diff --git a/packages/core/test/shared/uriTemplate.test.ts b/packages/core/test/shared/uriTemplate.test.ts index 3954901c4..937b237e2 100644 --- a/packages/core/test/shared/uriTemplate.test.ts +++ b/packages/core/test/shared/uriTemplate.test.ts @@ -311,4 +311,68 @@ describe('UriTemplate', () => { expect(elapsed).toBeLessThan(100); }); }); + + describe('optional query parameter matching (RFC 6570)', () => { + it('should match when no query parameters are present', () => { + const template = new UriTemplate('dom://{pageId}{?selector,includeAttributes,includeText,includeChildren}'); + const match = template.match('dom://5a072bc8-a8c7-43c3-84ac-154651ac5d44'); + expect(match).toEqual({ pageId: '5a072bc8-a8c7-43c3-84ac-154651ac5d44' }); + }); + + it('should match when a subset of query parameters are present', () => { + const template = new UriTemplate('dom://{pageId}{?selector,includeAttributes,includeText,includeChildren}'); + const match = template.match('dom://5a072bc8-a8c7-43c3-84ac-154651ac5d44?selector=body'); + expect(match).toEqual({ pageId: '5a072bc8-a8c7-43c3-84ac-154651ac5d44', selector: 'body' }); + }); + + it('should match when query parameters are in a different order', () => { + const template = new UriTemplate('dom://{pageId}{?selector,includeAttributes}'); + const match = template.match('dom://page1?includeAttributes=true&selector=body'); + expect(match).toEqual({ pageId: 'page1', includeAttributes: 'true', selector: 'body' }); + }); + + it('should match when all query parameters are present', () => { + const template = new UriTemplate('dom://{pageId}{?selector,includeAttributes,includeText,includeChildren}'); + const match = template.match('dom://page1?selector=body&includeAttributes=true&includeText=true&includeChildren=true'); + expect(match).toEqual({ + pageId: 'page1', + selector: 'body', + includeAttributes: 'true', + includeText: 'true', + includeChildren: 'true' + }); + }); + + it('should match with a single optional query param template', () => { + const template = new UriTemplate('/search{?q}'); + expect(template.match('/search')).toEqual({}); + expect(template.match('/search?q=test')).toEqual({ q: 'test' }); + }); + + it('should match multiple optional query params with partial presence', () => { + const template = new UriTemplate('/search{?q,page,limit}'); + expect(template.match('/search')).toEqual({}); + expect(template.match('/search?q=test')).toEqual({ q: 'test' }); + expect(template.match('/search?page=2&limit=10')).toEqual({ page: '2', limit: '10' }); + expect(template.match('/search?q=test&page=1&limit=10')).toEqual({ q: 'test', page: '1', limit: '10' }); + }); + + it('should ignore unknown query parameters not in the template', () => { + const template = new UriTemplate('/search{?q,page}'); + const match = template.match('/search?q=test&unknown=value&page=1'); + expect(match).toEqual({ q: 'test', page: '1' }); + }); + + it('should handle encoded query parameter values', () => { + const template = new UriTemplate('/search{?q}'); + const match = template.match('/search?q=hello%20world'); + expect(match).toEqual({ q: 'hello%20world' }); + }); + + it('should still reject URIs that do not match the base path', () => { + const template = new UriTemplate('/users/{id}{?fields}'); + expect(template.match('/posts/123')).toBeNull(); + expect(template.match('/users/123/extra')).toBeNull(); + }); + }); });