Repository navigation
Conversation
`UriTemplate.match` built one mandatory group per query name:
prefix + name + '=([^&]+)'
so a template like `/search{?q,page}` only matched a URI carrying *every*
pair. `expand` is the opposite: it drops a pair whose variable is undefined,
and it emits `name=` for an empty string. `match(expand({ q: 'test' }))`
therefore returned `null` for a URI the class had just produced.
Each query pair is now optional — `(?:\?name=([^&]*))?` — and a group that did
not participate is skipped instead of being read as `undefined`. The full-query
case is unchanged, and `/search` alone now matches the documented expansion of
an empty variable set.
|
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/core
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
abhiramshaji-cyber
left a comment
There was a problem hiding this comment.
Thanks for this, the writeup with the match(expand(...)) round trip made the bug really easy to follow, and the three tests read nicely. I tried a few more subsets and found one that still returns null (inline). Also changeset-bot says there's no changeset yet, not sure if you meant to add one. Happy to hear if I'm missing something here!
| @@ -214,8 +214,13 @@ export class UriTemplate { | |||
| for (let i = 0; i < part.names.length; i++) { | |||
| const name = part.names[i]!; | |||
| const prefix = i === 0 ? '\\' + part.operator : '&'; | |||
There was a problem hiding this comment.
I think the case where the first variable is left out still slips through. expand({ page: '1' }) gives /search?page=1, but the second pair has to start with &, so match returns null. Would something like this work for you?
| const prefix = i === 0 ? '\\' + part.operator : '&'; | |
| const prefix = i === 0 ? '\\' + part.operator : part.operator === '?' ? '[?&]' : '&'; |
With that, every subset I tried round trips (including {?a,b,c} with only b or c set) and the existing 41 tests still pass. Side note, /search&page=1 already matches on this branch with or without the change, not sure if that's worth tightening. What do you think?
| expect(template.match('/search?q=test')).toEqual({ q: 'test' }); | ||
| }); | ||
|
|
||
| it('keeps matching the full query string', () => { |
There was a problem hiding this comment.
Maybe worth one more case here for the leading variable being left out, like expect(template.match(template.expand({ page: '1' }))).toEqual({ page: '1' })?
What breaks
UriTemplatedisagrees with itself for query expressions.expandtreats a query variable as optional — it drops the pair when the variable isundefined:matchdoes not.partToRegExpbuilds one mandatory group per name:so the matcher demands every pair in the template:
A client that registers a resource template with optional query variables (
{?cursor,limit}is the common shape: the server expands it with whatever it needs, and the client then asks whether a resource URI belongs to the template) can never match a partially expanded URI. The second failure mode is the same bug seen from the other side:expand({ q: '' })emits/search?q=, and([^&]+)refuses that too, because it requires at least one character.Fix
Each query pair becomes optional and the value may be empty:
pattern: `(?:${prefix}${this.escapeRegExp(name)}=([^&]*))?`A group that did not participate is skipped rather than turned into
undefined:match(t.expand(vars))can then no longer benullfor reasons the class itself created, and an omitted variable is simply absent from the result ({ q: 'test' }, not{ q: 'test', page: undefined }).Order, literal text and the mandatory path segments are untouched: the groups stay in template order and every other operator keeps its existing pattern.
Tests
Three cases added to
packages/core-internal/test/shared/uriTemplate.test.ts(matching complex patterns):match(expand({ q: 'test' }))→{ q: 'test' }match('/search?q=test')→{ q: 'test' }match('/search?q=test&page=1')→{ q: 'test', page: '1' }(unchanged)Results (vitest, Node v22.22.2, Windows):
2 failed | 39 passed— cases 1 and 2 fail withexpected null to deeply equal { q: 'test' }, the control and all 36 pre-existing cases pass;41 passed(36 pre-existing + 2 new + control).Counter-proof: the new tests were re-run with
uriTemplate.tsrestored byte-for-byte frommainand only the test file kept — the same two cases fail, the control still passes.Evidence boundary
uriTemplate.tsin a standalone vitest workspace (this machine has no pnpm install of the repo), with the repo's own test file; the assertions use only the module's public API.prettier --checkwith the repo config (.prettierrc.json: 4 spaces, single quotes, printWidth 140) passes on both files.tsc --noEmit --strictpasses onuriTemplate.ts;match[i + 1]narrowing tostring | undefinedis what the new guard consumes.{?a}against a bare path now returns{}); that is the documented expansion of an empty variable set, but it is a visible semantic change for such callers and CI will run the package's own suites.