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');