From 0c20a9bf949d8cfe5a1519dbcb29071a37f019f3 Mon Sep 17 00:00:00 2001 From: Nishchay Mahor Date: Mon, 17 Aug 2026 00:48:28 -0700 Subject: [PATCH] fix(core): match multi-variable URI templates like `{a,b}` (#2166) `UriTemplate.expand` already joins multi-name expansions with commas, but `match` only emitted one regex capture per part and assigned the whole captured run to the first name. Anything past the first variable silently never matched and resources registered against templates like `data://users/{userId,format}` were unreachable. Emit one capture per name with literal commas between them in `partToRegExp`, mirroring what `expandPart` produces, so round tripping through expand and match recovers the original variables. Path / label / fragment operators get their existing literal prefix on the first capture; the bare and reserved cases just sit at the current position. --- .../uri-template-multi-variable-match.md | 21 +++++++++++ .../core-internal/src/shared/uriTemplate.ts | 35 +++++++++++++++++++ .../test/shared/uriTemplate.test.ts | 19 ++++++++++ 3 files changed, 75 insertions(+) create mode 100644 .changeset/uri-template-multi-variable-match.md diff --git a/.changeset/uri-template-multi-variable-match.md b/.changeset/uri-template-multi-variable-match.md new file mode 100644 index 0000000000..d48d6ba937 --- /dev/null +++ b/.changeset/uri-template-multi-variable-match.md @@ -0,0 +1,21 @@ +--- +'@modelcontextprotocol/core-internal': patch +'@modelcontextprotocol/server': patch +--- + +Match multi-variable URI template expressions like `{userId,format}`. + +`UriTemplate.expand` joins a multi-name expansion with commas, as RFC 6570 §3.2.2 +requires, but `match` emitted a single regex capture per part and assigned the whole +captured run to `part.name` — the first name only. The pattern for the bare operator, +`([^/,]+)`, also excludes the comma that `expand` had just written, so the URI failed to +match at all and `match` returned `null`. + +`partToRegExp` now emits one capture per name for a multi-name part, with a literal comma +between them, mirroring `expandPart`. The `/`, `.` and `#` operators keep their literal +prefix on the first capture; the bare and reserved forms sit at the current position. +Single-name parts and the `?` / `&` query forms are untouched. + +The visible effect is in `McpServer`: a resource registered against a template such as +`data://users/{userId,format}` never matched, so its handler was unreachable and the +`resources/read` was answered as unknown. diff --git a/packages/core-internal/src/shared/uriTemplate.ts b/packages/core-internal/src/shared/uriTemplate.ts index 5ffe213acd..1ef335f258 100644 --- a/packages/core-internal/src/shared/uriTemplate.ts +++ b/packages/core-internal/src/shared/uriTemplate.ts @@ -222,6 +222,41 @@ export class UriTemplate { return patterns; } + // Multi-variable expressions like `{a,b}` or `{/a,b}` expand each value + // and join them with commas (see `expandPart`). Mirror that on the way + // back by emitting one capture per name with literal commas between + // them. Without this, only the first name was assigned and the rest of + // the path silently never matched (#2166). + if (part.names.length > 1) { + let firstPrefix: string; + switch (part.operator) { + case '/': { + firstPrefix = '/'; + break; + } + case '.': { + firstPrefix = String.raw`\.`; + break; + } + case '#': { + firstPrefix = '#'; + break; + } + default: { + firstPrefix = ''; + } + } + for (let i = 0; i < part.names.length; i++) { + const name = part.names[i]!; + const prefix = i === 0 ? firstPrefix : ','; + patterns.push({ + pattern: prefix + '([^/,]+)', + name + }); + } + return patterns; + } + let pattern: string; const name = part.name; diff --git a/packages/core-internal/test/shared/uriTemplate.test.ts b/packages/core-internal/test/shared/uriTemplate.test.ts index bfc3237872..c75e3f3c9e 100644 --- a/packages/core-internal/test/shared/uriTemplate.test.ts +++ b/packages/core-internal/test/shared/uriTemplate.test.ts @@ -98,6 +98,25 @@ describe('UriTemplate', () => { expect(match).toEqual({ username: 'fred', postId: '123' }); }); + it('should match a comma-separated multi-variable expression (#2166)', () => { + const template = new UriTemplate('/users/{userId,format}'); + const match = template.match('/users/42,json'); + expect(match).toEqual({ userId: '42', format: 'json' }); + }); + + it('should round-trip expand and match for a multi-variable expression (#2166)', () => { + const template = new UriTemplate('data://users/{userId,format}'); + const expanded = template.expand({ userId: '42', format: 'json' }); + expect(expanded).toBe('data://users/42,json'); + expect(template.match(expanded)).toEqual({ userId: '42', format: 'json' }); + }); + + it('should match a multi-variable expression with the path operator (#2166)', () => { + const template = new UriTemplate('{/userId,format}'); + const match = template.match('/42,json'); + expect(match).toEqual({ userId: '42', format: 'json' }); + }); + it('should return null for non-matching URIs', () => { const template = new UriTemplate('/users/{username}'); const match = template.match('/posts/123');