Skip to content

Commit d3aaaaf

Browse files
committed
fix(mcp): match resource templates in one pass and keep a __proto__ value
A template with several values in one segment, such as `{a}-{b}-{c}`, compiled to a regular expression with overlapping `([^/]+)` groups, so a crafted URI could backtrack long enough to block the process. Templates now match in one pass from left to right with no regular expression: each value runs to the first place the next text appears. Two expressions with nothing between them (`{a}{b}`) throw DF0084, since a URI cannot show where one value ends. The params object has no prototype, so a variable named `__proto__` keeps its value.
1 parent 1a68378 commit d3aaaaf

5 files changed

Lines changed: 77 additions & 31 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 one non-empty URI segment, without `/`, and `read` gets its value percent-decoded, so a value with a `/` comes in as `%2F`. Any other template syntax 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. 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

‎docs/content/6.errors/DF0084.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ description: 'MCP resource template "{name}" has an invalid `uriTemplate` "{uriT
99
1010
## Cause
1111

12-
An entry in the `resourceTemplates` list of the MCP route options (or of `createMcpServer` / `createMcpFetchHandler`) has a `uriTemplate` that goes beyond RFC 6570 level 1. The MCP server supports plain `{name}` expressions only. A name is made of letters, digits, `_` and `.`, and each name appears once. An operator (`{+path}`, `{?q}`, `{/id}`), a modifier (`{path*}`, `{id:3}`), a list of names (`{a,b}`), an empty expression (`{}`), or a `{` or `}` outside an expression throws this. The check runs once, when the MCP route mounts or `createMcpServer` starts, so no request is served with a broken template.
12+
An entry in the `resourceTemplates` list of the MCP route options (or of `createMcpServer` / `createMcpFetchHandler`) has a `uriTemplate` that goes beyond RFC 6570 level 1. The MCP server supports plain `{name}` expressions only. A name is made of letters, digits, `_` and `.`, and each name appears once. An operator (`{+path}`, `{?q}`, `{/id}`), a modifier (`{path*}`, `{id:3}`), a list of names (`{a,b}`), an empty expression (`{}`), two expressions with nothing between them (`{a}{b}`, where a URI cannot show where one value ends), or a `{` or `}` outside an expression throws this. The check runs once, when the MCP route mounts or `createMcpServer` starts, so no request is served with a broken template.
1313

1414
## Example
1515

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

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -756,11 +756,33 @@ describe('mcp adapter (in-memory)', () => {
756756
})
757757

758758
it('throws DF0084 for a template beyond RFC 6570 level 1', () => {
759-
for (const uriTemplate of ['test://file/{+path}', 'test://search{?q}', 'test://pair/{a,b}', 'test://file/{path*}', 'test://file/{path', 'test://file/path}', 'test://pair/{a}/{a}', 'test://file/{}']) {
759+
for (const uriTemplate of ['test://file/{+path}', 'test://search{?q}', 'test://pair/{a,b}', 'test://file/{path*}', 'test://file/{path', 'test://file/path}', 'test://pair/{a}/{a}', 'test://file/{}', 'test://pair/{a}{b}']) {
760760
expect(() => compileResourceTemplates([{ uriTemplate, name: 'bad', read: () => ({}) }])).toThrow(expect.objectContaining({ code: 'DF0084' }))
761761
}
762762
expect(compileResourceTemplates([{ uriTemplate: 'test://fixed', name: 'fixed', read: () => ({}) }])[0]!.match('test://fixed')).toEqual({})
763763
})
764+
765+
it('matches several values in one segment, each up to the next literal', () => {
766+
const [range] = compileResourceTemplates([{ uriTemplate: 'test://range/{from}-{to}.json', name: 'range', read: () => ({}) }])
767+
expect(range!.match('test://range/1-2.json')).toEqual({ from: '1', to: '2' })
768+
expect(range!.match('test://range/1-2-3.json')).toEqual({ from: '1', to: '2-3' })
769+
for (const uri of ['test://range/-2.json', 'test://range/1-.json', 'test://range/1.json', 'test://range/1-2.txt', 'test://range/1-2/3.json'])
770+
expect(range!.match(uri)).toBeUndefined()
771+
})
772+
773+
it('keeps a value named __proto__', () => {
774+
const [proto] = compileResourceTemplates([{ uriTemplate: 'test://proto/{__proto__}', name: 'proto', read: () => ({}) }])
775+
expect(Object.entries(proto!.match('test://proto/x')!)).toEqual([['__proto__', 'x']])
776+
})
777+
778+
it('rejects a long URI that matches no template without backtracking', () => {
779+
const [many] = compileResourceTemplates([{ uriTemplate: 'test://many/{a}-{b}-{c}-{d}.json', name: 'many', read: () => ({}) }])
780+
// Ends like a match, but the `/` near the end fails it only after every value is read.
781+
const uri = `test://many/${'x-'.repeat(25_000)}/.json`
782+
const start = performance.now()
783+
expect(many!.match(uri)).toBeUndefined()
784+
expect(performance.now() - start).toBeLessThan(500)
785+
})
764786
})
765787

766788
describe('onResourcesChanged', () => {

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

Lines changed: 49 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -450,17 +450,13 @@ const TEMPLATE_EXPRESSION_RE = /\{([^{}]*)\}/g
450450
/** RFC 6570 `varname`: letters, digits and `_`, with single dots between them. */
451451
const TEMPLATE_VARNAME_RE = /^\w+(?:\.\w+)*$/
452452

453-
function escapeRegExp(text: string): string {
454-
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
455-
}
456-
457453
/**
458454
* Compile resource templates to URI matchers, once per endpoint. Only RFC
459455
* 6570 level 1 is supported: plain `{name}` expressions. Each expression
460-
* matches one non-empty URI segment (no `/`), and its value is
461-
* percent-decoded, so an encoded `%2F` reads back as `/`. A URI with
462-
* malformed percent-encoding matches no template. Any other template syntax
463-
* throws `DF0084`.
456+
* matches a non-empty value with no `/`, and its value is percent-decoded,
457+
* so an encoded `%2F` reads back as `/`. A URI with malformed
458+
* percent-encoding matches no template. Any other template syntax, and two
459+
* expressions with nothing between them, throw `DF0084`.
464460
*
465461
* @internal
466462
*/
@@ -472,7 +468,8 @@ function compileResourceTemplate(template: McpResourceTemplate): CompiledResourc
472468
const { name, uriTemplate } = template
473469
const invalid = (reason: string) => diagnostics.DF0084({ name, uriTemplate, reason })
474470
const names: string[] = []
475-
let source = '^'
471+
// The text around the expressions: one more than `names`.
472+
const literals: string[] = []
476473
let last = 0
477474
for (const expression of uriTemplate.matchAll(TEMPLATE_EXPRESSION_RE)) {
478475
const literal = uriTemplate.slice(last, expression.index)
@@ -483,33 +480,59 @@ function compileResourceTemplate(template: McpResourceTemplate): CompiledResourc
483480
throw invalid(`the expression "{${varname}}" is not a plain \`{name}\`.`)
484481
if (names.includes(varname))
485482
throw invalid(`the name "${varname}" is used more than once.`)
483+
if (names.length > 0 && literal === '')
484+
throw invalid(`"{${names.at(-1)}}" and "{${varname}}" have nothing between them, so a URI cannot show where one ends.`)
485+
literals.push(literal)
486486
names.push(varname)
487-
source += `${escapeRegExp(literal)}([^/]+)`
488487
last = expression.index + expression[0].length
489488
}
490489
const tail = uriTemplate.slice(last)
491490
if (/[{}]/.test(tail))
492491
throw invalid('a `{` or `}` is outside an expression.')
493-
const pattern = new RegExp(`${source}${escapeRegExp(tail)}$`)
492+
literals.push(tail)
494493

495494
return {
496495
template,
497-
match(uri) {
498-
const found = pattern.exec(uri)
499-
if (!found)
500-
return undefined
501-
const params: Record<string, string> = {}
502-
try {
503-
names.forEach((varname, index) => {
504-
params[varname] = decodeURIComponent(found[index + 1]!)
505-
})
506-
}
507-
catch {
508-
return undefined
509-
}
510-
return params
511-
},
496+
match: uri => matchTemplate(uri, literals, names),
497+
}
498+
}
499+
500+
/**
501+
* Match `uri` in one pass from left to right, with no regular expression, so
502+
* 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 `/`.
505+
* The params object has no prototype, so a variable such as `__proto__`
506+
* keeps its value.
507+
*/
508+
function matchTemplate(uri: string, literals: string[], names: string[]): Record<string, string> | undefined {
509+
const head = literals[0]!
510+
const tail = literals.at(-1)!
511+
const params: Record<string, string> = Object.create(null)
512+
if (names.length === 0)
513+
return uri === head ? params : undefined
514+
if (!uri.startsWith(head) || !uri.endsWith(tail))
515+
return undefined
516+
const end = uri.length - tail.length
517+
let position = head.length
518+
for (let index = 0; index < names.length; index++) {
519+
const isLast = index === names.length - 1
520+
const next = literals[index + 1]!
521+
const stop = isLast ? end : uri.indexOf(next, position + 1)
522+
if (stop <= position || stop > end)
523+
return undefined
524+
const raw = uri.slice(position, stop)
525+
if (raw.includes('/'))
526+
return undefined
527+
try {
528+
params[names[index]!] = decodeURIComponent(raw)
529+
}
530+
catch {
531+
return undefined
532+
}
533+
position = stop + (isLast ? 0 : next.length)
512534
}
535+
return params
513536
}
514537

515538
/**

‎packages/devframe/src/types/mcp.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,9 @@ export interface McpResourceTemplateContent {
148148
/**
149149
* A resource template the MCP server lists in `resources/templates/list` and
150150
* reads in `resources/read`. The `uriTemplate` uses RFC 6570 level 1 only:
151-
* plain `{name}` expressions. Each expression matches one non-empty URI
152-
* segment (no `/`), and its value is percent-decoded.
151+
* plain `{name}` expressions, with text between any two of them. Each
152+
* expression matches a non-empty value with no `/`, and its value is
153+
* percent-decoded.
153154
*
154155
* `list` and `read` get the node context of the devframe (or of the hub), so
155156
* they can read live state such as `ctx.rpc.sharedState` or `ctx.agent`. The

0 commit comments

Comments
 (0)