Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
7775907
update UriTemplate implementation to handle optional or out-of-order …
mgyarmathy Nov 5, 2025
3634992
chore: add changeset
felixweinberger Mar 26, 2026
6fcd8c4
omit absent query params from match result
felixweinberger Mar 26, 2026
7e4fd95
docs: add migration note for UriTemplate query param handling
felixweinberger Mar 27, 2026
fde6754
fix(core): handle malformed percent-encoding in UriTemplate.match()
felixweinberger Mar 27, 2026
01bcc47
fix(core): handle literal ? in templates and fix incomplete * replace…
felixweinberger Mar 27, 2026
79d782e
docs: drop migration.md entry since fix is backported to v1.x
felixweinberger Mar 27, 2026
a3cc896
Merge branch 'main' into fweinberger/uri-template-query-params
felixweinberger Mar 27, 2026
7e25ee3
fix(core): add boundary assertion to hasLiteralQuery regex and addres…
felixweinberger Mar 27, 2026
fb65293
Merge branch 'fweinberger/uri-template-query-params' of github.com:mo…
felixweinberger Mar 27, 2026
2fc117c
Merge branch 'main' into fweinberger/uri-template-query-params
felixweinberger Mar 30, 2026
37d2534
Merge branch 'main' into fweinberger/uri-template-query-params
felixweinberger Mar 30, 2026
5e07261
Merge remote-tracking branch 'origin/main' into fweinberger/uri-templ…
felixweinberger Mar 31, 2026
8a9aefb
refactor(core): reject literal-? in templates at construction; fix fr…
felixweinberger Mar 31, 2026
a911de3
Merge branch 'main' into fweinberger/uri-template-query-params
felixweinberger Apr 1, 2026
585c5f7
fix: make ?/# pre-processing conditional on template operators; don't…
felixweinberger Apr 9, 2026
a1f54f4
Merge branch 'main' into fweinberger/uri-template-query-params
KKonstantinov Apr 16, 2026
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-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, out-of-order, and URL-encoded query parameters. Previously, query parameters had to appear in the exact order specified in the template and omitted parameters would cause match failures. Omitted query parameters are now absent from the result (rather than set to `''`), so callers can use `vars.param ?? defaultValue`.
81 changes: 70 additions & 11 deletions packages/core/src/shared/uriTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@
const MAX_TEMPLATE_EXPRESSIONS = 10_000;
const MAX_REGEX_LENGTH = 1_000_000; // 1MB

function safeDecode(s: string): string {
try {
return decodeURIComponent(s);
} catch {
return s;
}
}

export class UriTemplate {
/**
* Returns true if the given string contains any URI template expressions.
Expand Down Expand Up @@ -97,7 +105,7 @@
return expr
.slice(operator.length)
.split(',')
.map(name => name.replace('*', '').trim())
.map(name => name.replaceAll('*', '').trim())
.filter(name => name.length > 0);
}

Expand Down Expand Up @@ -254,12 +262,25 @@

match(uri: string): Variables | null {
UriTemplate.validateLength(uri, MAX_TEMPLATE_LENGTH, 'URI');

// Check whether any literal string segment in the template contains a
// '?'. If so, the template author has written a manual query-string
// prefix (e.g. `/path?fixed=1{&var}`) and we cannot simply split the
// URI at its first '?' — the path regex itself needs to consume past it.
const hasLiteralQuery = this.parts.some(part => typeof part === 'string' && part.includes('?'));

// Build regex pattern for path (non-query) parts
let pattern = '^';
const names: Array<{ name: string; exploded: boolean }> = [];
const queryParts: Array<{ name: string; exploded: boolean }> = [];

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) {
queryParts.push({ name, exploded: part.exploded });
}
} else {
const patterns = this.partToRegExp(part);
for (const { pattern: partPattern, name } of patterns) {
Expand All @@ -269,22 +290,60 @@
}
}

pattern += '$';
UriTemplate.validateLength(pattern, MAX_REGEX_LENGTH, 'Generated regex pattern');
const regex = new RegExp(pattern);
const match = uri.match(regex);

if (!match) return null;
let match: RegExpMatchArray | null;
let queryPart: string;

if (hasLiteralQuery) {
// Match the path regex against the full URI without a trailing
// anchor, then treat everything after the match as the remaining
// query string to parse for {?...}/{&...} expressions.
UriTemplate.validateLength(pattern, MAX_REGEX_LENGTH, 'Generated regex pattern');
const regex = new RegExp(pattern);
match = uri.match(regex);
if (!match) return null;
queryPart = uri.slice(match[0].length).replace(/^&/, '');

Check failure on line 304 in packages/core/src/shared/uriTemplate.ts

View check run for this annotation

Claude / Claude Code Review

Unanchored regex in hasLiteralQuery path causes false positive matches

In the `hasLiteralQuery` branch (line 296-304), the regex has no trailing anchor, so it prefix-matches literal query values. For example, template `/path?id=1{&extra}` matched against `/path?id=100` returns `{}` instead of `null`, because `^/path\?id=1` matches the prefix `/path?id=1` within `/path?id=100`. Adding a boundary assertion like `(?=[&#]|$)` after the pattern would fix this.
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
} else {
// Split URI into path and query parts at the first '?'
const queryIndex = uri.indexOf('?');
const pathPart = queryIndex === -1 ? uri : uri.slice(0, queryIndex);
queryPart = queryIndex === -1 ? '' : uri.slice(queryIndex + 1);

pattern += '$';
UriTemplate.validateLength(pattern, MAX_REGEX_LENGTH, 'Generated regex pattern');
const regex = new RegExp(pattern);
match = pathPart.match(regex);
if (!match) return null;
}
Comment on lines +287 to +294
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The fragment-stripping guard at line 289 only checks p.operator === '#', but the {+var} (reserved expansion) operator also uses (.+) in partToRegExp and is RFC 6570-defined to pass reserved characters including # — so 'http://e.com{+rest}'.match('http://e.com/path#section') returns { rest: '/path' } instead of { rest: '/path#section' }. The fix is to extend hasHashOperator to also include templates with + operators: this.parts.some(p => typeof p \!== 'string' && (p.operator === '#' || p.operator === '+')).

Extended reasoning...

What the bug is

The match() method introduced in this PR adds a pre-processing step that strips the URI fragment before regex matching (lines 287–294). The guard condition hasHashOperator is supposed to skip this stripping when a {#var} operator is present, because the fragment is part of what the path regex must capture. However, the {+var} (reserved expansion) operator also generates the pattern (.+) in partToRegExp (via case '+': case '#': pattern = '(.+)') and is RFC 6570-defined to pass reserved characters through unencoded — including #. The check only looks at p.operator === '#' and never considers p.operator === '+'.

The specific code path

For template 'http://e.com{+rest}' and URI 'http://e.com/path#section':

  1. hasHashOperator = this.parts.some(p => typeof p \!== 'string' && p.operator === '#')false (only + operator present)
  2. Fragment IS stripped: hashIndex finds #, so working = 'http://e.com/path'
  3. queryParts.length === 0 (no ?/& operators) → no query split; pathPart = 'http://e.com/path'
  4. Path regex ^http://e\.com(.+)$ is matched against 'http://e.com/path' → captures '/path'
  5. Result: { rest: '/path' } — fragment silently dropped

Why existing code doesn't prevent it

Both + and # operators generate identical (.+) patterns in partToRegExp. The hasHashOperator guard was added to protect # captures from fragment stripping, but the symmetric case for + was overlooked. The check is simply incomplete.

Impact

This is a regression introduced by this PR. Before the PR, match() applied the regex to the full URI string, so (.+) correctly captured '/path#section'. After the PR, the fragment is stripped first, silently breaking round-trip fidelity for {+var} templates. The round-trip guarantee advertised by the PR (expand(match(uri)) === uri) fails: expand({ rest: '/path#section' }) yields 'http://e.com/path#section', but match('http://e.com/path#section') returns { rest: '/path' }, so a second expand produces 'http://e.com/path'.

How to fix

Extend hasHashOperator to also cover the + operator:

const hasReservedOrHashOperator = this.parts.some(
  p => typeof p \!== 'string' && (p.operator === '#' || p.operator === '+')
);

Step-by-step proof

  1. Template: 'http://e.com{+rest}', parsed parts: ['http://e.com', { operator: '+', names: ['rest'], ... }]
  2. hasHashOperator = false (the part's operator is '+', not '#')
  3. hashIndex = uri.indexOf('#') = 20working = 'http://e.com/path'
  4. queryParts.length = 0pathPart = 'http://e.com/path'
  5. Regex /^http:\/\/e\.com(.+)$/ matches 'http://e.com/path'match[1] = '/path'
  6. result = { rest: '/path' }'#section' is permanently lost

Comment on lines +289 to +294
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 🟡 The hasHashOperator guard only checks for {#var} operator parts, not for literal # characters in string segments — so a template like app://view#tab-{n} has its incoming URI truncated at # but the path regex still requires the literal #tab-, and match() always returns null. This is the literal-# analogue of the literal-? issue (already fixed by gating the query split on queryParts.length > 0); the fragment-stripping guard needs the same treatment, e.g. this.parts.some(p => typeof p === 'string' ? p.includes('#') : p.operator === '#'). Note this is distinct from the {+var} comment on the same line — that fix would not cover string segments.

Extended reasoning...

What the bug is

The fragment-stripping guard at line 289 computes hasHashOperator by checking only whether any expression part has operator === '#'. It does not consider literal # characters appearing in string segments of the template. RFC 6570 §2.1 explicitly permits reserved characters including # as template literals, so app://view#tab-{n} is a valid template. When such a template is matched, the URI's fragment is stripped at line 293, but the literal #tab- from the string segment is still baked into the path regex — so the regex can never match and match() always returns null.

Step-by-step proof

For template app://view#tab-{n} and URI app://view#tab-1:

  1. parse() produces this.parts = ['app://view#tab-', {operator: '', names: ['n'], ...}]
  2. Parts loop: the string segment is escaped (note escapeRegExp does not escape #, /, or : — they're not regex metacharacters), so pattern = '^app://view#tab-'; the {n} expression appends ([^/,]+). Final pattern = '^app://view#tab-([^/,]+)'.
  3. hasHashOperator = this.parts.some(p => typeof p \!== 'string' && p.operator === '#')false (the only non-string part has operator '').
  4. hashIndex = 'app://view#tab-1'.indexOf('#') = 10working = 'app://view' (fragment stripped).
  5. queryParts.length === 0 → no query split; pathPart = 'app://view'.
  6. Anchor appended: pattern = '^app://view#tab-([^/,]+)$'.
  7. 'app://view'.match(/^app:\/\/view#tab-([^\/,]+)$/)null.

Before this PR, step 3–4 didn't exist — match() ran the regex against the full uri string, so 'app://view#tab-1'.match(...) returned {n: '1'}. This is a regression.

Why existing code doesn't prevent it

This is the literal-# analogue of the literal-? regression that was iteratively addressed earlier in this PR. The literal-? case was fixed by gating the query split on queryParts.length > 0 (so a template with a literal ? and no {?}/{&} operators matches against the full URI). But fragment stripping is gated only on the {#} operator, not on whether any string part contains a literal # — the symmetric fix was not applied.

This is also distinct from the existing comment on line 294 about {+var}. That comment proposes extending the guard to p.operator === '#' || p.operator === '+', which still only inspects non-string parts and would not cover a literal # in a string segment.

Impact

Any resource template with a literal # in its string portion (e.g., custom-scheme URIs that use # as a literal delimiter rather than a fragment) silently stops matching. Niche, but it's a clean regression of previously-working behavior in a general-purpose RFC 6570 implementation.

How to fix

Extend the guard to also skip stripping when any string part contains a literal #:

const hasHashOperator = this.parts.some(p =>
    typeof p === 'string' ? p.includes('#') : p.operator === '#'
);

(or combine with the {+var} fix: typeof p === 'string' ? p.includes('#') : p.operator === '#' || p.operator === '+').


const result: Variables = {};
for (const [i, name_] of names.entries()) {
const { name, exploded } = name_!;
const value = match[i + 1]!;
const cleanName = name.replace('*', '');

for (const [i, { name, exploded }] of names.entries()) {
const value = match[i + 1]!;
const cleanName = name.replaceAll('*', '');
result[cleanName] = exploded && value.includes(',') ? value.split(',') : value;
}
Comment on lines +317 to 321
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Path variable captures in match() are returned as raw percent-encoded strings (lines 317-321), while query parameters are decoded via safeDecode() (lines 329-330) — an asymmetry newly introduced by this PR. This breaks the round-trip fidelity goal the PR describes: expand({ q: 'hello world' }) for {+q} yields /hello%20world, but match('/hello%20world') returns { q: 'hello%20world' }, so a second expand() double-encodes to /hello%2520world. The fix is to apply safeDecode to path variable captures in the names loop at lines 317-321, mirroring the query param handling.

Extended reasoning...

What the bug is

This PR adds safeDecode() calls when parsing query parameters (lines 329-330) so that {?q} matching ?q=hello%20world returns { q: 'hello world' }. However, path variable captures at lines 317-321 are returned without any decoding — the raw regex capture group value is stored directly in result[cleanName]. This creates a new inconsistency between the two handling paths.

The specific code path

In match(), path variable captures go through the names loop with no decoding applied. Query params go through safeDecode() at lines 329-330 before being stored. The path variable loop has no equivalent decoding step.

Why existing code does not prevent it

Before this PR, neither path variables nor query params were decoded — the behavior was symmetrically raw. This PR adds decoding only for query params, introducing a split where one code path decodes and the other does not. The path variable loop at lines 317-321 was not updated to match.

Impact and round-trip failure

The PR description explicitly advertises URL-encoded values are decoded and round-trip fidelity (expand(match(uri)) === uri) as goals. These goals hold for query params but fail for path operators:

  1. expand({ q: 'hello world' }) with template {+q} yields /hello%20world (via encodeURI)
  2. match('/hello%20world') returns { q: 'hello%20world' } (NOT decoded — returns raw)
  3. expand({ q: 'hello%20world' }) with template {+q} yields /hello%2520world (double-encoded!)

This also affects the simple {var} operator: template /users/{id} matching /users/john%20doe returns { id: 'john%20doe' }, while {?id} matching ?id=john%20doe returns { id: 'john doe' }.

Addressing the refutation

One verifier argues this is pre-existing because path variable non-decoding predates the PR and lines 317-321 are unchanged. This is technically accurate, but the PR creates a new user-visible inconsistency: prior to this PR, both operator types behaved the same (both raw). After this PR, the behavior diverges. The PR's own stated goals imply users should expect decoding to work uniformly, making this worth flagging as part of this PR rather than as a separate issue.

Fix

Apply safeDecode to path variable values in the names loop at lines 317-321, similar to the query param handling at lines 329-330. This mirrors the query param decoding and satisfies the round-trip guarantee for all operator types.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Path-variable decoding is pre-existing behavior (base code never decoded either) and out of scope for this query-param fix. Will track applying safeDecode() to path captures separately.


if (queryParts.length > 0) {
const queryParams = new Map<string, string>();
if (queryPart) {
for (const pair of queryPart.split('&')) {
const equalIndex = pair.indexOf('=');
if (equalIndex !== -1) {
const key = safeDecode(pair.slice(0, equalIndex));
const value = safeDecode(pair.slice(equalIndex + 1));
queryParams.set(key, value);
Comment thread
claude[bot] marked this conversation as resolved.
}
}
}

for (const { name, exploded } of queryParts) {
const cleanName = name.replaceAll('*', '');
const value = queryParams.get(cleanName);
if (value === undefined) continue;
result[cleanName] = exploded && value.includes(',') ? value.split(',') : value;
}
Comment on lines +336 to +341
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The exploded query param handler decodes values via safeDecode() before checking for commas, so a percent-encoded comma (%2C) in a single array element gets turned into a literal comma and causes an incorrect split. For example, expand({tags: ['a,b']}) with {?tags*} produces ?tags=a%2Cb, but match('/path?tags=a%2Cb') returns {tags: ['a', 'b']} instead of {tags: ['a,b']}. Fix by splitting on commas in the raw encoded value first, then decoding each element individually.

Extended reasoning...

What the bug is and how it manifests

In match(), query parameter values are decoded via safeDecode() at line 330 and stored in the queryParams Map as decoded strings. Then at line 340, the exploded check exploded && value.includes(',') ? value.split(',') : value operates on the already-decoded value. For a single array element containing a literal comma (e.g., tag value "a,b"), encodeURIComponent encodes the comma to %2C during expansion, but safeDecode converts it back to , before the split check, incorrectly creating two array elements.

The specific code path

  1. Line 330 (in the query params parsing loop): const value = safeDecode(pair.slice(equalIndex + 1)); — this decodes the full value, including any %2C sequences into literal commas.
  2. Line 340 (in the queryParts processing loop): result[cleanName] = exploded && value.includes(',') ? value.split(',') : value; — this operates on the already-decoded string, so "a,b" (decoded from "a%2Cb") is split into ['a', 'b'].

Why existing code does not prevent it

Prior to this PR, neither path variable captures nor query parameters were decoded — both returned raw encoded strings. The comma-split check was always performed on the raw encoded string, where %2C would not trigger a split. This PR introduces safeDecode() for query parameters but reuses the existing post-decode comma-split check, creating a new bug: commas that were intentionally percent-encoded to be part of a single value are now treated as array delimiters.

What the impact would be

Any {?name*} or {&name*} template whose elements contain literal commas will fail round-trip fidelity. Specifically, expand(match(uri)) \!== uri for any URI where an exploded array element contains %2C. This is a regression introduced by this PR — the pre-PR behavior was at least consistent (always returning raw strings).

How to fix it

Split on literal commas in the raw encoded value first, then decode each element individually:

// Instead of:
const value = safeDecode(pair.slice(equalIndex + 1));
queryParams.set(key, value);
// ...later...
result[cleanName] = exploded && value.includes(',') ? value.split(',') : value;

// Do:
const rawValue = pair.slice(equalIndex + 1);
queryParams.set(key, rawValue); // store raw, not decoded
// ...later...
if (exploded && rawValue.includes(',')) {
    result[cleanName] = rawValue.split(',').map(safeDecode);
} else {
    result[cleanName] = safeDecode(rawValue);
}

Step-by-step proof

  1. Template: {?tags*}, value: {tags: ['a,b', 'c']}
  2. expand({tags: ['a,b', 'c']})?tags=a%2Cb,c (encodeURIComponent encodes the comma inside 'a,b' to %2C, the inter-element comma is left literal)
  3. In match('/path?tags=a%2Cb,c'):
    • pair = 'tags=a%2Cb,c'
    • safeDecode('a%2Cb,c') = 'a,b,c' (DECODED: now has two commas)
    • queryParams.set('tags', 'a,b,c')
    • exploded=true, 'a,b,c'.includes(',') = true
    • 'a,b,c'.split(',') = ['a', 'b', 'c'] — WRONG (should be ['a,b', 'c'])
  4. Correct approach: split 'a%2Cb,c' on literal commas → ['a%2Cb', 'c'], then decode each → ['a,b', 'c']

}

return result;
}
}
84 changes: 84 additions & 0 deletions packages/core/test/shared/uriTemplate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,11 +191,95 @@ describe('UriTemplate', () => {
expect(template.variableNames).toEqual(['q', 'page']);
});

it('should handle partial query parameter matches correctly', () => {
const template = new UriTemplate('/search{?q,page}');
const match = template.match('/search?q=test');
expect(match).toEqual({ q: 'test' });
expect(template.variableNames).toEqual(['q', 'page']);
});

it('should match multiple query parameters if provided in a different order', () => {
const template = new UriTemplate('/search{?q,page}');
const match = template.match('/search?page=1&q=test');
expect(match).toEqual({ q: 'test', page: '1' });
expect(template.variableNames).toEqual(['q', 'page']);
});

it('should still match if additional query parameters are provided', () => {
const template = new UriTemplate('/search{?q,page}');
const match = template.match('/search?q=test&page=1&sort=desc');
expect(match).toEqual({ q: 'test', page: '1' });
expect(template.variableNames).toEqual(['q', 'page']);
});

it('should match omitted query parameters', () => {
const template = new UriTemplate('/search{?q,page}');
const match = template.match('/search');
expect(match).toEqual({});
expect(template.variableNames).toEqual(['q', 'page']);
});

it('should distinguish absent from empty query parameters', () => {
const template = new UriTemplate('/search{?q,page}');
const match = template.match('/search?q=');
expect(match).toEqual({ q: '' });
});

it('should match nested path segments with query parameters', () => {
const template = new UriTemplate('/api/{version}/{resource}{?apiKey,q,p,sort}');
const match = template.match('/api/v1/users?apiKey=testkey&q=user');
expect(match).toEqual({
version: 'v1',
resource: 'users',
apiKey: 'testkey',
q: 'user'
});
expect(template.variableNames).toEqual(['version', 'resource', 'apiKey', 'q', 'p', 'sort']);
});

it('should handle partial matches correctly', () => {
const template = new UriTemplate('/users/{id}');
expect(template.match('/users/123/extra')).toBeNull();
expect(template.match('/users')).toBeNull();
});

it('should handle encoded query parameters', () => {
const template = new UriTemplate('/search{?q}');
const match = template.match('/search?q=hello%20world');
expect(match).toEqual({ q: 'hello world' });
expect(template.variableNames).toEqual(['q']);
});

it('should not throw on malformed percent-encoding in query parameters', () => {
const template = new UriTemplate('/search{?q}');
expect(template.match('/search?q=100%')).toEqual({ q: '100%' });
expect(template.match('/search?q=%ZZ')).toEqual({ q: '%ZZ' });
});

it('should match templates with a literal ? followed by {&...} continuation', () => {
const template = new UriTemplate('/path?static=1{&dynamic}');
const match = template.match('/path?static=1&dynamic=hello');
expect(match).toEqual({ dynamic: 'hello' });
expect(template.variableNames).toEqual(['dynamic']);
});

it('should match templates with literal ? when continuation param is absent', () => {
const template = new UriTemplate('/path?static=1{&dynamic}');
const match = template.match('/path?static=1');
expect(match).toEqual({});
});

it('should match templates with literal ? and multiple continuation params', () => {
const template = new UriTemplate('/api?v=2{&key,page}');
const match = template.match('/api?v=2&page=3&key=abc');
expect(match).toEqual({ key: 'abc', page: '3' });
});

it('should match path variables combined with literal ? and {&...}', () => {
const template = new UriTemplate('/api/{version}?format=json{&key}');
const match = template.match('/api/v1?format=json&key=secret');
expect(match).toEqual({ version: 'v1', key: 'secret' });
});
});

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