-
Notifications
You must be signed in to change notification settings - Fork 1.8k
fix: UriTemplate.match() handles optional, out-of-order, and encoded query parameters #1785
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 5 commits
7775907
3634992
6fcd8c4
7e4fd95
fde6754
01bcc47
79d782e
a3cc896
7e25ee3
fb65293
2fc117c
37d2534
5e07261
8a9aefb
a911de3
585c5f7
a1f54f4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
|
@@ -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
|
||
|
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) { | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 isThis 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 pathIn 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 itBefore 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 failureThe 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:
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 refutationOne 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. FixApply 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
|
claude[bot] marked this conversation as resolved.
|
||
| } | ||
| } | ||
| } | ||
|
|
||
| for (const { name, exploded } of queryParts) { | ||
| const cleanName = name.replace('*', ''); | ||
|
|
||
| const value = queryParams.get(cleanName); | ||
| if (value === undefined) continue; | ||
| result[cleanName] = exploded && value.includes(',') ? value.split(',') : value; | ||
| } | ||
|
Comment on lines
+336
to
+341
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 The exploded query param handler decodes values via Extended reasoning...What the bug is and how it manifests In The specific code path
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 What the impact would be Any 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
|
||
| } | ||
|
|
||
| return result; | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.