From 77759070eedf3d0312b9357512bb44b66fd0389c Mon Sep 17 00:00:00 2001 From: Michael Gyarmathy Date: Wed, 5 Nov 2025 14:16:04 -0600 Subject: [PATCH 1/8] update UriTemplate implementation to handle optional or out-of-order query parameters --- packages/core/src/shared/uriTemplate.ts | 83 ++++++++++++++++--- packages/core/test/shared/uriTemplate.test.ts | 49 +++++++++++ 2 files changed, 120 insertions(+), 12 deletions(-) diff --git a/packages/core/src/shared/uriTemplate.ts b/packages/core/src/shared/uriTemplate.ts index 5ffe213ac..a95629571 100644 --- a/packages/core/src/shared/uriTemplate.ts +++ b/packages/core/src/shared/uriTemplate.ts @@ -254,35 +254,94 @@ export class UriTemplate { 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); + + // Build regex pattern for path (non-query) parts let pattern = '^'; - const names: Array<{ name: string; exploded: boolean }> = []; + const names: Array<{ name: string; exploded: boolean; isQuery: boolean }> = []; + const queryParts: Array<{ name: string; exploded: boolean }> = []; for (const part of this.parts) { if (typeof part === 'string') { pattern += this.escapeRegExp(part); } else { - const patterns = this.partToRegExp(part); - for (const { pattern: partPattern, name } of patterns) { - pattern += partPattern; - names.push({ name, exploded: part.exploded }); + if (part.operator === '?' || part.operator === '&') { + // Collect query parameter names for later extraction + for (const name of part.names) { + queryParts.push({ name, exploded: part.exploded }); + } + } else { + // Handle non-query parts normally + const patterns = this.partToRegExp(part); + for (const { pattern: partPattern, name } of patterns) { + pattern += partPattern; + names.push({ name, exploded: part.exploded, isQuery: false }); + } } } } - pattern += '$'; + // Match the path part (without query parameters) + pattern += '(?:\\?.*)?$'; // Allow optional query string at the end 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_!; - const value = match[i + 1]!; - const cleanName = name.replace('*', ''); - result[cleanName] = exploded && value.includes(',') ? value.split(',') : value; + // Extract non-query parameters + let matchIndex = 0; + for (const { name, exploded, isQuery } of names) { + if (!isQuery) { + const value = match[matchIndex + 1]; + const cleanName = name.replace('*', ''); + + if (value === undefined) { + result[cleanName] = ''; + } else if (exploded && value && value.includes(',')) { + result[cleanName] = value.split(','); + } else { + result[cleanName] = value; + } + matchIndex++; + } + } + + // Extract query parameters from query string + if (queryParts.length > 0) { + const queryParams = new Map(); + if (queryPart) { + // Parse query string + const pairs = queryPart.split('&'); + for (const pair of pairs) { + const equalIndex = pair.indexOf('='); + if (equalIndex !== -1) { + const key = decodeURIComponent(pair.slice(0, equalIndex)); + const value = decodeURIComponent(pair.slice(equalIndex + 1)); + queryParams.set(key, value); + } + } + } + + // Extract values for each expected query parameter + for (const { name, exploded } of queryParts) { + const cleanName = name.replace('*', ''); + const value = queryParams.get(cleanName); + + if (value === undefined) { + result[cleanName] = ''; + } else if (exploded && value.includes(',')) { + result[cleanName] = value.split(','); + } else { + result[cleanName] = value; + } + } } return result; diff --git a/packages/core/test/shared/uriTemplate.test.ts b/packages/core/test/shared/uriTemplate.test.ts index 3954901c4..d4d67f043 100644 --- a/packages/core/test/shared/uriTemplate.test.ts +++ b/packages/core/test/shared/uriTemplate.test.ts @@ -191,11 +191,60 @@ 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', page: '' }); + 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({ q: '', page: '' }); + expect(template.variableNames).toEqual(['q', 'page']); + }); + + 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', + p: '', + sort: '' + }); + 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']); + }); }); describe('security and edge cases', () => { From 363499251cf606bdf66d8a4c44665de4198fabfd Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 26 Mar 2026 18:12:27 +0000 Subject: [PATCH 2/8] chore: add changeset --- .changeset/fix-uri-template-query-params.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-uri-template-query-params.md diff --git a/.changeset/fix-uri-template-query-params.md b/.changeset/fix-uri-template-query-params.md new file mode 100644 index 000000000..0578083ce --- /dev/null +++ b/.changeset/fix-uri-template-query-params.md @@ -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. From 6fcd8c462f626e646c1f91f2bd9d26903f43e8fc Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 26 Mar 2026 18:18:27 +0000 Subject: [PATCH 3/8] omit absent query params from match result 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. --- .changeset/fix-uri-template-query-params.md | 2 +- packages/core/src/shared/uriTemplate.ts | 61 ++++++------------- packages/core/test/shared/uriTemplate.test.ts | 14 +++-- 3 files changed, 27 insertions(+), 50 deletions(-) diff --git a/.changeset/fix-uri-template-query-params.md b/.changeset/fix-uri-template-query-params.md index 0578083ce..369c8d0e5 100644 --- a/.changeset/fix-uri-template-query-params.md +++ b/.changeset/fix-uri-template-query-params.md @@ -2,4 +2,4 @@ '@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. +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`. diff --git a/packages/core/src/shared/uriTemplate.ts b/packages/core/src/shared/uriTemplate.ts index a95629571..5d5c02054 100644 --- a/packages/core/src/shared/uriTemplate.ts +++ b/packages/core/src/shared/uriTemplate.ts @@ -262,31 +262,26 @@ export class UriTemplate { // Build regex pattern for path (non-query) parts let pattern = '^'; - const names: Array<{ name: string; exploded: boolean; isQuery: boolean }> = []; + 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 { - if (part.operator === '?' || part.operator === '&') { - // Collect query parameter names for later extraction - for (const name of part.names) { - queryParts.push({ name, exploded: part.exploded }); - } - } else { - // Handle non-query parts normally - const patterns = this.partToRegExp(part); - for (const { pattern: partPattern, name } of patterns) { - pattern += partPattern; - names.push({ name, exploded: part.exploded, isQuery: false }); - } + const patterns = this.partToRegExp(part); + for (const { pattern: partPattern, name } of patterns) { + pattern += partPattern; + names.push({ name, exploded: part.exploded }); } } } - // Match the path part (without query parameters) - pattern += '(?:\\?.*)?$'; // Allow optional query string at the end + pattern += '$'; UriTemplate.validateLength(pattern, MAX_REGEX_LENGTH, 'Generated regex pattern'); const regex = new RegExp(pattern); const match = pathPart.match(regex); @@ -295,31 +290,16 @@ export class UriTemplate { const result: Variables = {}; - // Extract non-query parameters - let matchIndex = 0; - for (const { name, exploded, isQuery } of names) { - if (!isQuery) { - const value = match[matchIndex + 1]; - const cleanName = name.replace('*', ''); - - if (value === undefined) { - result[cleanName] = ''; - } else if (exploded && value && value.includes(',')) { - result[cleanName] = value.split(','); - } else { - result[cleanName] = value; - } - matchIndex++; - } + 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; } - // Extract query parameters from query string if (queryParts.length > 0) { const queryParams = new Map(); if (queryPart) { - // Parse query string - const pairs = queryPart.split('&'); - for (const pair of pairs) { + for (const pair of queryPart.split('&')) { const equalIndex = pair.indexOf('='); if (equalIndex !== -1) { const key = decodeURIComponent(pair.slice(0, equalIndex)); @@ -329,18 +309,11 @@ export class UriTemplate { } } - // Extract values for each expected query parameter for (const { name, exploded } of queryParts) { const cleanName = name.replace('*', ''); const value = queryParams.get(cleanName); - - if (value === undefined) { - result[cleanName] = ''; - } else if (exploded && value.includes(',')) { - result[cleanName] = value.split(','); - } else { - result[cleanName] = value; - } + if (value === undefined) continue; + result[cleanName] = exploded && value.includes(',') ? value.split(',') : value; } } diff --git a/packages/core/test/shared/uriTemplate.test.ts b/packages/core/test/shared/uriTemplate.test.ts index d4d67f043..f0dd5faa5 100644 --- a/packages/core/test/shared/uriTemplate.test.ts +++ b/packages/core/test/shared/uriTemplate.test.ts @@ -194,7 +194,7 @@ describe('UriTemplate', () => { 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', page: '' }); + expect(match).toEqual({ q: 'test' }); expect(template.variableNames).toEqual(['q', 'page']); }); @@ -215,10 +215,16 @@ describe('UriTemplate', () => { it('should match omitted query parameters', () => { const template = new UriTemplate('/search{?q,page}'); const match = template.match('/search'); - expect(match).toEqual({ q: '', page: '' }); + 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'); @@ -226,9 +232,7 @@ describe('UriTemplate', () => { version: 'v1', resource: 'users', apiKey: 'testkey', - q: 'user', - p: '', - sort: '' + q: 'user' }); expect(template.variableNames).toEqual(['version', 'resource', 'apiKey', 'q', 'p', 'sort']); }); From 7e4fd9585edd0d7821dc657bbba6eee957d750e9 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 27 Mar 2026 11:20:53 +0000 Subject: [PATCH 4/8] docs: add migration note for UriTemplate query param handling --- docs/migration.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/migration.md b/docs/migration.md index 21f8b67c9..5edaa42ad 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -759,6 +759,26 @@ try { ## Enhancements +### `UriTemplate.match()` now handles optional query parameters + +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: From fde67547e53afe2b49a031ce27ea4c1f1daee561 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 27 Mar 2026 14:25:52 +0000 Subject: [PATCH 5/8] fix(core): handle malformed percent-encoding in UriTemplate.match() --- packages/core/src/shared/uriTemplate.ts | 12 ++++++++++-- packages/core/test/shared/uriTemplate.test.ts | 6 ++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/core/src/shared/uriTemplate.ts b/packages/core/src/shared/uriTemplate.ts index 5d5c02054..592153039 100644 --- a/packages/core/src/shared/uriTemplate.ts +++ b/packages/core/src/shared/uriTemplate.ts @@ -7,6 +7,14 @@ const MAX_VARIABLE_LENGTH = 1_000_000; // 1MB 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. @@ -302,8 +310,8 @@ export class UriTemplate { for (const pair of queryPart.split('&')) { const equalIndex = pair.indexOf('='); if (equalIndex !== -1) { - const key = decodeURIComponent(pair.slice(0, equalIndex)); - const value = decodeURIComponent(pair.slice(equalIndex + 1)); + const key = safeDecode(pair.slice(0, equalIndex)); + const value = safeDecode(pair.slice(equalIndex + 1)); queryParams.set(key, value); } } diff --git a/packages/core/test/shared/uriTemplate.test.ts b/packages/core/test/shared/uriTemplate.test.ts index f0dd5faa5..77486090b 100644 --- a/packages/core/test/shared/uriTemplate.test.ts +++ b/packages/core/test/shared/uriTemplate.test.ts @@ -249,6 +249,12 @@ describe('UriTemplate', () => { 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', () => { From 01bcc474a5704adae7fbb8b73de4c2ef4798a120 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 27 Mar 2026 16:33:48 +0000 Subject: [PATCH 6/8] fix(core): handle literal ? in templates and fix incomplete * replacement --- packages/core/src/shared/uriTemplate.ts | 45 +++++++++++++------ packages/core/test/shared/uriTemplate.test.ts | 25 +++++++++++ 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/packages/core/src/shared/uriTemplate.ts b/packages/core/src/shared/uriTemplate.ts index 592153039..f822e55b1 100644 --- a/packages/core/src/shared/uriTemplate.ts +++ b/packages/core/src/shared/uriTemplate.ts @@ -105,7 +105,7 @@ export class UriTemplate { return expr .slice(operator.length) .split(',') - .map(name => name.replace('*', '').trim()) + .map(name => name.replaceAll('*', '').trim()) .filter(name => name.length > 0); } @@ -263,10 +263,11 @@ export class UriTemplate { 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 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 = '^'; @@ -289,18 +290,36 @@ export class UriTemplate { } } - pattern += '$'; - UriTemplate.validateLength(pattern, MAX_REGEX_LENGTH, 'Generated regex pattern'); - const regex = new RegExp(pattern); - const match = pathPart.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(/^&/, ''); + } 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; + } const result: Variables = {}; for (const [i, { name, exploded }] of names.entries()) { const value = match[i + 1]!; - const cleanName = name.replace('*', ''); + const cleanName = name.replaceAll('*', ''); result[cleanName] = exploded && value.includes(',') ? value.split(',') : value; } @@ -318,7 +337,7 @@ export class UriTemplate { } for (const { name, exploded } of queryParts) { - const cleanName = name.replace('*', ''); + const cleanName = name.replaceAll('*', ''); const value = queryParams.get(cleanName); if (value === undefined) continue; result[cleanName] = exploded && value.includes(',') ? value.split(',') : value; diff --git a/packages/core/test/shared/uriTemplate.test.ts b/packages/core/test/shared/uriTemplate.test.ts index 77486090b..fa87ca863 100644 --- a/packages/core/test/shared/uriTemplate.test.ts +++ b/packages/core/test/shared/uriTemplate.test.ts @@ -255,6 +255,31 @@ describe('UriTemplate', () => { 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', () => { From 79d782e4d29ae18701f85c56f5a40d01e42dc710 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 27 Mar 2026 17:58:02 +0000 Subject: [PATCH 7/8] docs: drop migration.md entry since fix is backported to v1.x Per review feedback on #1785: since #1083 backports this fix to v1.x, users migrating v1.x-latest to v2 won't see a behavior change here. --- docs/migration.md | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index 5edaa42ad..21f8b67c9 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -759,26 +759,6 @@ try { ## Enhancements -### `UriTemplate.match()` now handles optional query parameters - -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: From 7e25ee3a84ce3d7fd06a5451389f335d4ac9443b Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 27 Mar 2026 18:30:24 +0000 Subject: [PATCH 8/8] fix(core): add boundary assertion to hasLiteralQuery regex and address edge cases --- packages/core/src/shared/uriTemplate.ts | 17 ++++++++++++---- packages/core/test/shared/uriTemplate.test.ts | 20 +++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/packages/core/src/shared/uriTemplate.ts b/packages/core/src/shared/uriTemplate.ts index f822e55b1..7a3e338ae 100644 --- a/packages/core/src/shared/uriTemplate.ts +++ b/packages/core/src/shared/uriTemplate.ts @@ -294,14 +294,23 @@ export class UriTemplate { 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. + // Match the path regex against the full URI. The lookahead + // assertion ensures the literal portion ends exactly at a + // query-param separator, fragment, or end-of-string, so a + // template like `?id=1` does not spuriously prefix-match + // `?id=100`. + pattern += '(?=[&#]|$)'; 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(/^&/, ''); + // Everything after the match is the remaining query string to + // parse for {?...}/{&...} expressions. Strip any fragment and + // the leading `&` separator first. + let rest = uri.slice(match[0].length); + const hashIndex = rest.indexOf('#'); + if (hashIndex !== -1) rest = rest.slice(0, hashIndex); + queryPart = rest.replace(/^&/, ''); } else { // Split URI into path and query parts at the first '?' const queryIndex = uri.indexOf('?'); diff --git a/packages/core/test/shared/uriTemplate.test.ts b/packages/core/test/shared/uriTemplate.test.ts index fa87ca863..a39585f8e 100644 --- a/packages/core/test/shared/uriTemplate.test.ts +++ b/packages/core/test/shared/uriTemplate.test.ts @@ -280,6 +280,26 @@ describe('UriTemplate', () => { const match = template.match('/api/v1?format=json&key=secret'); expect(match).toEqual({ version: 'v1', key: 'secret' }); }); + + it('should not prefix-match literal query values with literal ?', () => { + // `?id=1` must not match `?id=100` + const template = new UriTemplate('/path?id=1{&extra}'); + expect(template.match('/path?id=100')).toBeNull(); + expect(template.match('/path?id=1')).toEqual({}); + expect(template.match('/path?id=1&extra=x')).toEqual({ extra: 'x' }); + }); + + it('should require a proper separator after the literal ? portion', () => { + // malformed URI missing `&` between params must not match + const template = new UriTemplate('/path?a=1{&b}'); + expect(template.match('/path?a=1b=2')).toBeNull(); + }); + + it('should ignore fragments after the literal ? portion', () => { + const template = new UriTemplate('/path?a=1{&b}'); + expect(template.match('/path?a=1#section')).toEqual({}); + expect(template.match('/path?a=1&b=foo#section')).toEqual({ b: 'foo' }); + }); }); describe('security and edge cases', () => {