Skip to content

Commit 61427ef

Browse files
committed
fix(mcp): do not split a template value inside a percent escape
A value ran to the first place the next literal appeared, even when that place was inside a `%XX` escape of the value. With `{a}D{b}`, the URI `a%2DbDc` split at the `D` of `%2D`, so `a` could not be decoded and a valid URI matched no template. The search now skips places inside an escape, so `a` reads `a-b` and `b` reads `c`.
1 parent d81d1d1 commit 61427ef

3 files changed

Lines changed: 25 additions & 4 deletions

File tree

‎docs/content/2.adapters/7.mcp.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ devframeViteBridge(myDevframe, {
9999
})
100100
```
101101

102-
The `uriTemplate` uses RFC 6570 level 1: plain `{name}` expressions. Each one matches a non-empty value without `/`, and `read` gets its value percent-decoded, so a value with a `/` comes in as `%2F`. When one segment has several values, such as `{from}-{to}`, each value runs up to the first place the next text appears. Any other template syntax, or two expressions with nothing between them (`{a}{b}`), throws [DF0084](/errors/DF0084) when the route mounts. `read` returns `text`, or `json` that the server sends as JSON text, and an optional `mimeType` (the default is the `mimeType` of the template, then `application/json`).
102+
The `uriTemplate` uses RFC 6570 level 1: plain `{name}` expressions. Each one matches a non-empty value without `/`, and `read` gets its value percent-decoded, so a value with a `/` comes in as `%2F`. When one segment has several values, such as `{from}-{to}`, each value runs up to the first place the next text appears outside a `%XX` escape. Any other template syntax, or two expressions with nothing between them (`{a}{b}`), throws [DF0084](/errors/DF0084) when the route mounts. `read` returns `text`, or `json` that the server sends as JSON text, and an optional `mimeType` (the default is the `mimeType` of the template, then `application/json`).
103103

104104
`resources/read` sends a `devframe://resource/<id>` or `devframe://state/<key>` URI to the registered resource or the shared state as before. It tries any other URI against the templates in order, and the first template that matches reads it. A URI that nothing matches gets the usual unknown-resource error, and an error thrown by `read` goes to the MCP client like the error of a registered resource. `resources/list` adds the entries that the `list` of each template returns. With an empty or omitted list, the resources stay as they are and `resources/templates/list` answers method not found. `createMcpServer` and `createMcpFetchHandler` take the same `resourceTemplates` option.
105105

‎packages/agentic/src/mcp/__tests__/mcp-server.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -770,6 +770,14 @@ describe('mcp adapter (in-memory)', () => {
770770
expect(range!.match(uri)).toBeUndefined()
771771
})
772772

773+
it('does not split a value inside a percent-encoded character', () => {
774+
const [pair] = compileResourceTemplates([{ uriTemplate: 'test://pair/{a}D{b}', name: 'pair', read: () => ({}) }])
775+
expect(pair!.match('test://pair/a%2DbDc')).toEqual({ a: 'a-b', b: 'c' })
776+
expect(pair!.match('test://pair/a%2dbDc')).toEqual({ a: 'a-b', b: 'c' })
777+
const [hex] = compileResourceTemplates([{ uriTemplate: 'test://hex/{a}2D{b}', name: 'hex', read: () => ({}) }])
778+
expect(hex!.match('test://hex/x%2D2Dy')).toEqual({ a: 'x-', b: 'y' })
779+
})
780+
773781
it('keeps a value named __proto__', () => {
774782
const [proto] = compileResourceTemplates([{ uriTemplate: 'test://proto/{__proto__}', name: 'proto', read: () => ({}) }])
775783
expect(Object.entries(proto!.match('test://proto/x')!)).toEqual([['__proto__', 'x']])

‎packages/agentic/src/mcp/build-server.ts‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -500,8 +500,9 @@ function compileResourceTemplate(template: McpResourceTemplate): CompiledResourc
500500
/**
501501
* Match `uri` in one pass from left to right, with no regular expression, so
502502
* the time grows with the URI length only and a crafted URI cannot block the
503-
* process. Each value runs to the first place the next literal appears (the
504-
* last value runs to the tail). A value must not be empty or contain `/`.
503+
* process. Each value runs to the first place the next literal appears
504+
* outside a `%XX` escape (the last value runs to the tail). A value must not
505+
* be empty or contain `/`.
505506
* The params object has no prototype, so a variable such as `__proto__`
506507
* keeps its value.
507508
*/
@@ -518,7 +519,7 @@ function matchTemplate(uri: string, literals: string[], names: string[]): Record
518519
for (let index = 0; index < names.length; index++) {
519520
const isLast = index === names.length - 1
520521
const next = literals[index + 1]!
521-
const stop = isLast ? end : uri.indexOf(next, position + 1)
522+
const stop = isLast ? end : findLiteral(uri, next, position)
522523
if (stop <= position || stop > end)
523524
return undefined
524525
const raw = uri.slice(position, stop)
@@ -535,6 +536,18 @@ function matchTemplate(uri: string, literals: string[], names: string[]): Record
535536
return params
536537
}
537538

539+
/**
540+
* The first place `literal` appears after the value that starts at `start`
541+
* (which must not be empty), or -1. A place inside a `%XX` escape of the
542+
* value is skipped, so `{a}D{b}` reads `a%2DbDc` as `a-b` and `c`.
543+
*/
544+
function findLiteral(uri: string, literal: string, start: number): number {
545+
let at = uri.indexOf(literal, start + 1)
546+
while (at !== -1 && (uri[at - 1] === '%' || (at - 2 >= start && uri[at - 2] === '%')))
547+
at = uri.indexOf(literal, at + 1)
548+
return at
549+
}
550+
538551
/**
539552
* The content blocks a tool result carries through `withMcpContent`, checked
540553
* and copied field by field, or `undefined` when it carries none. Invalid

0 commit comments

Comments
 (0)