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(); + }); + }); });