Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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`.
20 changes: 20 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,26 @@ try {

## Enhancements

### `UriTemplate.match()` now handles optional query parameters
Comment thread
felixweinberger marked this conversation as resolved.
Outdated

Resource templates with query parameters (e.g. `products{?page,limit}`) previously required all query parameters to be present in the exact order specified, or the match would fail. Now they match per RFC 6570 semantics: query parameters are optional, order-independent, and URL-decoded.

```typescript
const template = new UriTemplate('products{?page,limit}');

// v1: returned null
// v2: returns {}
template.match('products');

// v1: returned null (wrong order)
// v2: returns { page: '1', limit: '10' }
template.match('products?limit=10&page=1');
```

Absent query parameters are omitted from the result (not set to `''`), so you can use `vars.page ?? defaultValue`. A parameter present with an empty value (e.g. `?page=`) returns `''`, distinguishing "absent" from "empty".

If you were relying on strict query parameter matching to reject requests, you'll now need to validate parameters explicitly in your resource callback.

### Automatic JSON Schema validator selection by runtime

The SDK now automatically selects the appropriate JSON Schema validator based on your runtime environment:
Expand Down
48 changes: 44 additions & 4 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 @@ -254,12 +262,24 @@

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

// Split URI into path and query parts
const queryIndex = uri.indexOf('?');
const pathPart = queryIndex === -1 ? uri : uri.slice(0, queryIndex);
const queryPart = queryIndex === -1 ? '' : uri.slice(queryIndex + 1);

Check warning on line 269 in packages/core/src/shared/uriTemplate.ts

View check run for this annotation

Claude / Claude Code Review

Templates with literal '?' break due to naive URI splitting

Templates with a literal `?` followed by `{&...}` continuation operators (e.g., `/path?static=1{&dynamic}`) regress in `match()`. The URI is split at the first `?` (line 267), so `pathPart` becomes `/path`, but the path regex includes the escaped literal `\\?static=1` from the string segment — causing the match to always fail. This is a niche but valid RFC 6570 pattern; the standard approach uses `{?param}` operators instead.
Comment thread
claude[bot] marked this conversation as resolved.
Outdated

// 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 @@ -272,19 +292,39 @@
pattern += '$';
UriTemplate.validateLength(pattern, MAX_REGEX_LENGTH, 'Generated regex pattern');
const regex = new RegExp(pattern);
const match = uri.match(regex);
const match = pathPart.match(regex);

if (!match) return null;

const result: Variables = {};
for (const [i, name_] of names.entries()) {
const { name, exploded } = name_!;

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

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.replace('*', '');
Comment thread Fixed
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;
}
}
59 changes: 59 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,70 @@ 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' });
});
});

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