Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/fix-uri-template-optional-query.md
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 30 additions & 9 deletions packages/core/src/shared/uriTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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 '+':
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
}
}
64 changes: 64 additions & 0 deletions packages/core/test/shared/uriTemplate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
Loading