Skip to content

fix: UriTemplate.match() handles optional, out-of-order, and encoded query parameters#1785

Open
felixweinberger wants to merge 16 commits intomainfrom
fweinberger/uri-template-query-params
Open

fix: UriTemplate.match() handles optional, out-of-order, and encoded query parameters#1785
felixweinberger wants to merge 16 commits intomainfrom
fweinberger/uri-template-query-params

Conversation

@felixweinberger
Copy link
Copy Markdown
Contributor

Supersedes #1396. Implementation by @mgyarmathy, with a refinement to how absent query parameters are represented.

Fixes UriTemplate.match() to correctly handle query parameter templates per RFC 6570 semantics:

  • Query parameters are now optional (previously required)
  • Query parameters can appear in any order (previously had to match template order exactly)
  • URL-encoded values are decoded
  • Extra query parameters in the URI are ignored

Motivation and Context

Fixes #149
Fixes #1079

Previously, a resource template like products{?page,limit} would fail to match products or products?limit=10&page=1 (wrong order), returning null and causing a "Resource not found" error. This made query parameter templates effectively unusable.

Design: absent parameters are omitted, not empty string

Absent query parameters are omitted from the result rather than set to ''. This differs from #1396, which returned empty strings. Rationale:

Round-trip fidelity. The SDK's expand() already distinguishes these cases:

expand({})          -> /items
expand({page: ''})  -> /items?page=

Omitting keys preserves expand(match(uri)) === uri. Returning '' would turn /items into /items?page=&limit= on round-trip.

RFC 6570 §2.3 explicitly distinguishes undefined from empty string ("A variable value that is a string of length zero is not considered undefined"). The RFC only specifies expansion, not matching, but this semantic distinction should be preserved in both directions.

Ecosystem convention. The reference JS implementation (uri-templates) omits keys for absent params.

Ergonomics. Enables the idiomatic vars.page ?? defaultValue pattern. Empty string would silently break ?? defaulting.

This means callers can distinguish "parameter absent" (vars.page === undefined) from "parameter present but empty" (vars.page === '', e.g. ?page=).

How Has This Been Tested?

New test cases cover: partial matches, out-of-order params, omitted params, extra params, encoded values, and the absent-vs-empty distinction. All 461 core tests pass.

Breaking Changes

Resource templates with query parameters now match more liberally. If you relied on strict all-params-required matching to reject requests, you'll need to validate explicitly in your callback. See docs/migration.md.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

Core implementation (path/query splitting, Map-based query parsing, URL decoding) by @mgyarmathy in #1396. This PR adds the absent-param-omission refinement, changeset, and migration note.

mgyarmathy and others added 4 commits March 26, 2026 18:11
Absent query parameters are now omitted from the result object rather
than set to empty string, so callers can use `vars.param ?? default`.
This also distinguishes 'param absent' from 'param present but empty'
(e.g. ?q= returns {q: ''}).

Also removes dead code: the isQuery field was always false since query
parts go to a separate array, and the (?:\\?.*)?$ regex suffix was
unreachable since pathPart already excludes the query string.
@felixweinberger felixweinberger requested a review from a team as a code owner March 27, 2026 12:03
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 27, 2026

🦋 Changeset detected

Latest commit: 585c5f7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@modelcontextprotocol/core Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 27, 2026

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@1785

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@1785

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@1785

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@1785

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@1785

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@1785

commit: 585c5f7

@felixweinberger felixweinberger marked this pull request as draft March 27, 2026 14:56
felixweinberger and others added 3 commits March 27, 2026 16:33
@felixweinberger felixweinberger marked this pull request as ready for review March 27, 2026 17:59
felixweinberger and others added 7 commits March 27, 2026 18:30
…delcontextprotocol/typescript-sdk into fweinberger/uri-template-query-params
…agment handling

- Remove the hasLiteralQuery special-case branch from match(). Four rounds
  of review found bugs in it; the two-code-path design was inherently fragile.
- Reject any literal '?' in template string segments at construction time
  with a clear error pointing at the {?param} alternative. No real-world
  MCP resource templates use this pattern.
- Fix fragment handling: strip #fragment before splitting path/query, so
  /path?a=1#frag correctly extracts {a:'1'} instead of {a:'1#frag'}.
- Replace 7 hasLiteralQuery tests with construction-validation tests and
  fragment-stripping tests.

Net: -33 lines of match() logic, one code path, correct by construction.
Comment on lines +317 to 321
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;
}
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.

Comment on lines +299 to +307
let pathPart = working;
let queryPart = '';
if (queryParts.length > 0) {
const queryIndex = working.indexOf('?');
if (queryIndex !== -1) {
pathPart = working.slice(0, queryIndex);
queryPart = working.slice(queryIndex + 1);
}
}
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 new match() routes {&} operators to queryParts (same as {?}) and splits the URI at ?, but a standalone {&} template (no preceding {?}) produces URIs with & but no ? — so the split finds nothing and the path regex (which omits the & part) fails to match. For template /path{&extra}, expand({extra: 'val'}) = '/path&extra=val', but match('/path&extra=val') returns null. Before this PR, partToRegExp for {&extra} generated \&extra=([^&]+) in the path regex, which matched correctly.

Extended reasoning...

What the bug is and how it manifests

The new match() implementation separates template parts into two buckets: path parts (used to build the regex) and query parts ({?}/{&} operators). For query parts, the URI is split at the first ? to extract the query string. However, RFC 6570's {&} (form-style query continuation) operator is specifically designed to add &key=val segments without a leading ?. When {&} is used standalone (no preceding {?} or literal ?), the expanded URI contains & but no ? delimiter.

The specific code path that triggers it

In match() at lines 274-280, part.operator === '&' routes the operator names into queryParts (same as {?}). At lines 302-307, queryParts.length > 0 causes the code to look for ? in the URI. For a standalone {&} template like /path{&extra}, the expanded URI /path&extra=val has no ?, so queryIndex = -1, pathPart = '/path&extra=val', queryPart = ''. The path regex is built as ^/path$ (the {&extra} part contributed nothing to the path regex, having been routed to queryParts). Matching '/path&extra=val' against ^/path$ fails and returns null.

Why existing code doesn't prevent it

Before this PR, partToRegExp was called for {&} operators and generated \&name=([^&]+) patterns added directly to the path regex. The full URI was matched against the full regex, so ^/path\&extra=([^&]+)$ correctly matched /path&extra=val. The new code short-circuits {?}/{&} before calling partToRegExp, routing them to queryParts instead — which only works when there is a ? to split at.

What the impact would be

Round-trip fidelity breaks: expand(match(uri)) \!== uri because match returns null for valid expanded URIs using standalone {&}. The expand side still works correctly (X{&var} expands to X&var=test per existing tests), making the asymmetry subtle. Any MCP resource template or client using {&} without a preceding {?} will silently fail to match.

How to fix it

Only route {&} operators to queryParts when the template also contains a {?} operator or a literal ? in a string segment (i.e., when there will actually be a ? in expanded URIs). Otherwise, fall back to calling partToRegExp for {&} parts (the pre-PR behavior), keeping them in the path regex where they can match the &key=val suffix directly.

Step-by-step proof

  1. Template: /path{&extra}, URI: /path&extra=val
  2. Parts loop: literal '/path'pattern = '^/path'; {&extra} → appended to queryParts = [{name:'extra'}]
  3. hasHashOperator = false; no # in URI → working = '/path&extra=val'
  4. queryParts.length > 0 → search for ? in '/path&extra=val'queryIndex = -1 (not found)
  5. pathPart = '/path&extra=val', queryPart = ''
  6. Final pattern = '^/path$'; '/path&extra=val'.match(/^\/path$/)null
  7. Returns null — regression from pre-PR behavior where ^/path\&extra=([^&]+)$ matched '/path&extra=val' and returned {extra: 'val'}

Comment on lines +287 to +294
// Only strip the URI fragment when the template has no {#var} operator;
// otherwise the fragment is part of what the path regex must capture.
const hasHashOperator = this.parts.some(p => typeof p !== 'string' && p.operator === '#');
let working = uri;
if (!hasHashOperator) {
const hashIndex = working.indexOf('#');
if (hashIndex !== -1) working = working.slice(0, hashIndex);
}
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 +336 to +341
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;
}
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']

Comment on lines +287 to +307
// Only strip the URI fragment when the template has no {#var} operator;
// otherwise the fragment is part of what the path regex must capture.
const hasHashOperator = this.parts.some(p => typeof p !== 'string' && p.operator === '#');
let working = uri;
if (!hasHashOperator) {
const hashIndex = working.indexOf('#');
if (hashIndex !== -1) working = working.slice(0, hashIndex);
}

// Only split path/query when the template actually has {?..}/{&..}
// operators. Otherwise match the path regex against the full URI so
// {+var} can capture across '?' as it did before query-param support.
let pathPart = working;
let queryPart = '';
if (queryParts.length > 0) {
const queryIndex = working.indexOf('?');
if (queryIndex !== -1) {
pathPart = working.slice(0, queryIndex);
queryPart = working.slice(queryIndex + 1);
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 When a template uses both {?param} and {#var} operators together (e.g., /path{?a}{#b}), match() always returns null for valid URIs like /path?a=1#section. The two new conditional preprocessing steps interact incorrectly: hasHashOperator=true prevents fragment stripping, but queryParts.length>0 then splits at '?', placing the fragment into queryPart ('a=1#section') and leaving pathPart='/path' which cannot satisfy the (.+) capture pattern that {#b} adds to the path regex. Additionally, even if matching succeeded, the fragment would leak into the query value for 'a'.

Extended reasoning...

What the bug is and how it manifests

When a template contains both a {?...} query operator and a {#var} fragment operator (e.g., /path{?a}{#b}), the match() method unconditionally returns null for any valid matching URI. This is caused by two new conditional preprocessing steps introduced by this PR that were designed independently but fail when combined.

The specific code path that triggers it

For template /path{?a}{#b} and URI /path?a=1#section:

  1. Parts loop: {?a} goes to queryParts; {#b} is sent to partToRegExp (operator # is not ?/&), which adds (.+) to pattern and b to names. Final path pattern: ^/path(.+)$.
  2. Fragment check: hasHashOperator = true, so the fragment is NOT stripped. working = /path?a=1#section.
  3. Query split: queryParts.length > 0, so split at ?: pathPart = /path, queryPart = a=1#section.
  4. Path match: /path matched against /^/path(.+)$/ returns null because (.+) requires at least one character after /path, but pathPart is exactly /path.

Result: null. Expected: {a: '1', b: '#section'}.

Why existing code does not prevent it

The hasHashOperator check was added to preserve the fragment for {#} capture; the queryParts.length > 0 check was added to split path from query for {?} parsing. Each works correctly in isolation. But when both operators appear in the same template, they interact destructively: fragment preservation keeps the fragment in working, but the query split then moves that fragment into queryPart rather than leaving it available for path regex capture by the {#} pattern.

Impact

Any URI template combining {?...} and {#...} operators always returns null from match(), even for perfectly valid matching URIs. Additionally, even if the path match were somehow forced to succeed, queryPart = a=1#section would assign value 1#section to variable a — meaning the fragment leaks into the query value.

How to fix

When both hasHashOperator and queryParts.length > 0 are true, the fragment must be separated before the ? split. One approach: always strip the fragment from working before splitting at ?, record the fragment separately, then pass it back to the path match when hasHashOperator is true (appending it to pathPart so the {#} pattern can capture it).

Step-by-step proof

  • Template: /path{?a}{#b}, URI: /path?a=1#section
  • After parts loop: pattern = '^/path(.+)$', names = [{name:'b'}], queryParts = [{name:'a'}]
  • hasHashOperator = true -> working = /path?a=1#section (no stripping)
  • queryParts.length > 0 -> split at ?: pathPart = /path, queryPart = a=1#section
  • /path.match(/^/path(.+)$/) -> null
  • match() returns null — BUG: should return {a: '1', b: '#section'}
  • No test in the test suite covers this combined {?}+{#} case

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

# MCP SDK ResourceTemplate URI Validation Issue: RFC 6570 Template Matching Behavior Unordered and partial query parameter matching

3 participants