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-params.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/core': patch
---

Fix `UriTemplate.match()` to correctly handle optional query parameters per RFC 6570. Templates using `{?param1,param2}` now match URIs with no query params, a subset of params, or params in a different order than declared in the template.
42 changes: 33 additions & 9 deletions packages/core/src/shared/uriTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,14 +211,7 @@ export class UriTemplate {
}

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
});
}
// Query params are handled directly in match() — skip here
return patterns;
}

Expand All @@ -227,7 +220,8 @@ export class UriTemplate {

switch (part.operator) {
case '': {
pattern = part.exploded ? '([^/,]+(?:,[^/,]+)*)' : '([^/,]+)';
// Exclude ? and # so path variables don't consume query/fragment delimiters
pattern = part.exploded ? '([^/?#,]+(?:,[^/?#,]+)*)' : '([^/?#,]+)';
break;
}
case '+':
Expand Down Expand Up @@ -256,10 +250,23 @@ export class UriTemplate {
UriTemplate.validateLength(uri, MAX_TEMPLATE_LENGTH, 'URI');
let pattern = '^';
const names: Array<{ name: string; exploded: boolean }> = [];
const queryParamNames: string[] = [];
let hasQueryOperator = false;

for (const part of this.parts) {
if (typeof part === 'string') {
pattern += this.escapeRegExp(part);
} else if (part.operator === '?' || part.operator === '&') {
// Validate variable name length for matching
for (const name of part.names) {
UriTemplate.validateLength(name, MAX_VARIABLE_LENGTH, 'Variable name');
queryParamNames.push(name);
}
// Allow an optional query string; the actual param extraction happens below
if (!hasQueryOperator) {
pattern += String.raw`(?:\?[^#]*)?`;
hasQueryOperator = true;
}
} else {
const patterns = this.partToRegExp(part);
for (const { pattern: partPattern, name } of patterns) {
Expand All @@ -285,6 +292,23 @@ 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) {
for (const pair of uri.slice(qIdx + 1).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;
}
}
38 changes: 38 additions & 0 deletions packages/core/test/shared/uriTemplate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,44 @@ describe('UriTemplate', () => {
expect(template.match('/users/123/extra')).toBeNull();
expect(template.match('/users')).toBeNull();
});

it('should match URI without any query params when template has optional query params', () => {
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 URI with a subset of optional query params', () => {
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 URI with query params in different order than the template', () => {
const template = new UriTemplate('resource://{id}{?param1,param2}');
const match = template.match('resource://test1?param2=valueA&param1=value1');
expect(match).toEqual({ id: 'test1', param1: 'value1', param2: 'valueA' });
});

it('should match URI with all optional query params', () => {
const template = new UriTemplate('dom://{pageId}{?selector,includeAttributes,includeText,includeChildren}');
const match = template.match(
'dom://5a072bc8-a8c7-43c3-84ac-154651ac5d44?selector=body&includeAttributes=true&includeText=true&includeChildren=true'
);
expect(match).toEqual({
pageId: '5a072bc8-a8c7-43c3-84ac-154651ac5d44',
selector: 'body',
includeAttributes: 'true',
includeText: 'true',
includeChildren: 'true'
});
});

it('should not include query params not listed in the template', () => {
const template = new UriTemplate('/search{?q}');
const match = template.match('/search?q=test&extra=ignored');
expect(match).toEqual({ q: 'test' });
});
});

describe('security and edge cases', () => {
Expand Down
Loading