Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/uri-template-multi-variable-match.md
Original file line number Diff line number Diff line change
@@ -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.
35 changes: 35 additions & 0 deletions packages/core-internal/src/shared/uriTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
19 changes: 19 additions & 0 deletions packages/core-internal/test/shared/uriTemplate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading