diff --git a/.changeset/quiet-rivers-match.md b/.changeset/quiet-rivers-match.md new file mode 100644 index 0000000000..8aac6c5848 --- /dev/null +++ b/.changeset/quiet-rivers-match.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/core-internal': patch +--- + +Fix URI template matching for optional query parameters. diff --git a/packages/core-internal/src/shared/uriTemplate.ts b/packages/core-internal/src/shared/uriTemplate.ts index 5ffe213acd..6bf80dd404 100644 --- a/packages/core-internal/src/shared/uriTemplate.ts +++ b/packages/core-internal/src/shared/uriTemplate.ts @@ -227,7 +227,7 @@ export class UriTemplate { switch (part.operator) { case '': { - pattern = part.exploded ? '([^/,]+(?:,[^/,]+)*)' : '([^/,]+)'; + pattern = part.exploded ? '([^/?,#]+(?:,[^/?,#]+)*)' : '([^/?,#]+)'; break; } case '+': @@ -240,7 +240,7 @@ export class UriTemplate { break; } case '/': { - pattern = '/' + (part.exploded ? '([^/,]+(?:,[^/,]+)*)' : '([^/,]+)'); + pattern = '/' + (part.exploded ? '([^/?,#]+(?:,[^/?,#]+)*)' : '([^/?,#]+)'); break; } default: { @@ -256,11 +256,27 @@ export class UriTemplate { UriTemplate.validateLength(uri, MAX_TEMPLATE_LENGTH, 'URI'); let pattern = '^'; const names: Array<{ name: string; exploded: boolean }> = []; + const queryNames: Array<{ name: string; exploded: boolean }> = []; + let queryCaptureCount = 0; for (const part of this.parts) { if (typeof part === 'string') { pattern += this.escapeRegExp(part); } else { + if (part.operator === '?' || part.operator === '&') { + for (const name of part.names) { + queryNames.push({ name, exploded: part.exploded }); + } + + if (queryCaptureCount === 0) { + pattern += part.operator === '?' ? String.raw`(?:\?([^#]*))?` : '(?:&([^#]*))?'; + } else if (part.operator === '&') { + pattern += '(?:&([^#]*))?'; + } + queryCaptureCount++; + continue; + } + const patterns = this.partToRegExp(part); for (const { pattern: partPattern, name } of patterns) { pattern += partPattern; @@ -285,6 +301,32 @@ export class UriTemplate { result[cleanName] = exploded && value.includes(',') ? value.split(',') : value; } + const queryOffset = names.length + 1; + const queryParams = new Map(); + for (let i = 0; i < queryCaptureCount; i++) { + const query = match[queryOffset + i]; + if (!query) continue; + + for (const pair of query.split('&')) { + if (!pair) continue; + + const separator = pair.indexOf('='); + const key = separator === -1 ? pair : pair.slice(0, separator); + const value = separator === -1 ? '' : pair.slice(separator + 1); + const values = queryParams.get(key) ?? []; + values.push(value); + queryParams.set(key, values); + } + } + + for (const { name, exploded } of queryNames) { + const values = queryParams.get(name); + if (!values) continue; + + const value = values.length > 1 ? values : values[0]!; + result[name] = exploded && !Array.isArray(value) && value.includes(',') ? value.split(',') : value; + } + return result; } } diff --git a/packages/core-internal/test/shared/uriTemplate.test.ts b/packages/core-internal/test/shared/uriTemplate.test.ts index bfc3237872..e6ecdebcd3 100644 --- a/packages/core-internal/test/shared/uriTemplate.test.ts +++ b/packages/core-internal/test/shared/uriTemplate.test.ts @@ -191,6 +191,31 @@ describe('UriTemplate', () => { expect(template.variableNames).toEqual(['q', 'page']); }); + it('should match query templates when optional parameters are omitted', () => { + 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 query template parameters in any order', () => { + const template = new UriTemplate('dom://{pageId}{?selector,includeAttributes,includeText,includeChildren}'); + const match = template.match('dom://5a072bc8-a8c7-43c3-84ac-154651ac5d44?includeText=true&selector=body'); + + expect(match).toEqual({ + pageId: '5a072bc8-a8c7-43c3-84ac-154651ac5d44', + selector: 'body', + includeText: 'true' + }); + }); + + it('should preserve encoded query values when matching query templates', () => { + const template = new UriTemplate('/search{?q}'); + const match = template.match('/search?q=value%20with%20spaces'); + + expect(match).toEqual({ q: 'value%20with%20spaces' }); + }); + it('should handle partial matches correctly', () => { const template = new UriTemplate('/users/{id}'); expect(template.match('/users/123/extra')).toBeNull();