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
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2280,7 +2280,16 @@ app.mcpAddTool({

Resource and prompt handlers also receive the same context object as tool handlers, enabling access to HTTP context and authorization information:

When `uriPattern` contains `{name}` placeholders (an RFC 6570 level-1 template), `resources/read` for a concrete URI such as `context://data/42` is routed to it, and the values are available as `context.uriParams` (`{ id: '42' }`). Each placeholder matches a single non-empty segment that does not contain `/`; an exact `uri` registration always takes precedence over a template.
When `uriPattern` is an RFC 6570 template, `resources/read` for a concrete URI such as `context://data/42` is routed to it, and the percent-decoded values are available as `context.uriParams` (`{ id: '42' }`). The supported expressions are:

| Expression | Example | Matches |
|------------|---------|---------|
| `{name}` | `x://docs/{id}` | a single non-empty segment that does not contain `/` |
| `{+name}` | `files://{+path}` | a non-empty value that may contain `/` (but not `?` or `#`) |
| `{/a,b}` | `x://items{/id}` | optional `/value` segments |
| `{?a,b}`, `{&c}` | `search://docs{?q,limit}{&page}` | optional query parameters in any order; must come last in the template, and unknown or repeated parameters do not match |

Registering a template that uses anything else (the `#`, `.` and `;` operators, `{a,b}` with several variables, or the `*` and `:n` modifiers) throws. An exact `uri` registration always takes precedence over a template; when several templates match, the one with the most literal characters wins (ties go to the first registered), so `x://files/{name}` wins over `x://{a}/{b}` for `x://files/a`. Values such as `{+path}` are not sanitized: validate them (for example against `..` segments) before using them as file paths.

```typescript
// Resource handler with context
Expand Down
4 changes: 4 additions & 0 deletions src/decorators/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
ResourceUnsubscribeHandler
} from '../types.ts'
import { callRegisteredTool } from '../handlers.ts'
import { isTemplateUri, compileUriTemplate } from '../uri-template.ts'
import { schemaToArguments, validateToolSchema, isTypeBoxSchema } from '../validation/index.ts'
import type { JsonSchemaValidator } from '../validation/json-schema-validator.ts'

Expand Down Expand Up @@ -97,6 +98,9 @@ const mcpDecoratorsPlugin: FastifyPluginAsync<MCPDecoratorsOptions> = async (app
throw new Error('Resource definition must have a uri or uriPattern')
}

// Throws for templates resources/read could never route to
if (isTemplateUri(uriPattern)) compileUriTemplate(uriPattern)

// Convert uriPattern to uri for the definition
const resourceDefinition = {
...definition,
Expand Down
78 changes: 8 additions & 70 deletions src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import type { JsonSchemaValidator } from './validation/json-schema-validator.ts'
import { sanitizeToolParams, assessToolSecurity } from './security.ts'
import { MCP_ATTR, type SpanAttributeValue } from './telemetry-constants.ts'
import { isStdioRequest } from './stdio-marker.ts'
import { isTemplateUri, compileUriTemplate, matchUriTemplate } from './uri-template.ts'

// Lazy-loaded telemetry module — only imported when a tracer is configured
let _telemetry: typeof import('./telemetry.ts') | undefined
Expand Down Expand Up @@ -281,73 +282,6 @@ async function handleToolsList (request: JSONRPCRequest, dependencies: HandlerDe
return createResponse(request.id, result)
}

const URI_TEMPLATE_REGEX = /\{[^}]+\}/

function isTemplateUri (uri: string): boolean {
return URI_TEMPLATE_REGEX.test(uri)
}

interface CompiledUriTemplate {
regex: RegExp
names: string[]
}

const compiledUriTemplates = new Map<string, CompiledUriTemplate | null>()

/**
* Compile an RFC 6570 level-1 template (`{name}` placeholders only) into an
* anchored regex. Each placeholder matches one non-empty, slash-bounded
* segment; everything else must match literally. Templates using operators
* (`{+x}`, `{?x}`, ...) are not supported and never match.
*/
function compileUriTemplate (template: string): CompiledUriTemplate | null {
let compiled = compiledUriTemplates.get(template)
if (compiled !== undefined) return compiled

const names: string[] = []
let source = '^'
let lastIndex = 0
let supported = true
for (const match of template.matchAll(/\{([^}]*)\}/g)) {
const name = match[1]
if (!/^[A-Za-z0-9_]+$/.test(name)) {
supported = false
break
}
source += escapeRegExp(template.slice(lastIndex, match.index)) + '([^/]+)'
names.push(name)
lastIndex = match.index + match[0].length
}
source += escapeRegExp(template.slice(lastIndex)) + '$'

compiled = supported ? { regex: new RegExp(source), names } : null
compiledUriTemplates.set(template, compiled)
return compiled
}

function escapeRegExp (value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

function matchUriTemplate (template: string, uri: string): Record<string, string> | null {
const compiled = compileUriTemplate(template)
if (!compiled) return null

const match = compiled.regex.exec(uri)
if (!match) return null

const params: Record<string, string> = {}
compiled.names.forEach((name, i) => {
const raw = match[i + 1]
try {
params[name] = decodeURIComponent(raw)
} catch {
params[name] = raw
}
})
return params
}

// Fields shared by the spec Resource and ResourceTemplate shapes. Anything else
// on a registered definition (uriPattern, uriSchema, ...) is a registration
// input and must not be listed: uriSchema would leak validation patterns.
Expand Down Expand Up @@ -779,16 +713,20 @@ async function handleResourcesRead (
}
}

// Otherwise look for a registered template the concrete URI expands from
// Otherwise look for a registered template the concrete URI expands from,
// preferring the one with the most literal characters (ties: registration order)
let uriParams: Record<string, string> | undefined
if (!resource) {
let bestLiteralLength = -1
for (const candidate of resources.values()) {
if (!isTemplateUri(candidate.definition.uri)) continue
const params = matchUriTemplate(candidate.definition.uri, uri)
const compiled = compileUriTemplate(candidate.definition.uri)
if (compiled.literalLength <= bestLiteralLength) continue
const params = matchUriTemplate(compiled, uri)
if (params) {
resource = candidate
uriParams = params
break
bestLiteralLength = compiled.literalLength
}
}
}
Expand Down
152 changes: 152 additions & 0 deletions src/uri-template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
const URI_TEMPLATE_REGEX = /\{[^}]+\}/
const VARNAME_REGEX = /^[A-Za-z0-9_]+$/

export function isTemplateUri (uri: string): boolean {
return URI_TEMPLATE_REGEX.test(uri)
}

export interface CompiledUriTemplate {
// Anchored regex for the URI, or for the part before '?' when the template
// has query expressions
regex: RegExp
names: string[]
// Variables of the trailing {?...}{&...} expressions, null when there are none
queryNames: string[] | null
// Number of literal characters, used to prefer the most specific template
literalLength: number
}

const compiledUriTemplates = new Map<string, CompiledUriTemplate>()

/**
* Compile the supported subset of RFC 6570 into a matcher:
*
* - `{name}`: one non-empty segment without `/`
* - `{+name}`: one non-empty value that may contain `/` (but not `?` or `#`)
* - `{/a,b}`: optional `/value` path segments
* - `{?a,b}{&c}`: optional query parameters, in any order; these must
* trail the template
*
* Anything else (other operators, prefix/explode modifiers, multi-variable
* `{a,b}` or `{+a,b}`) throws, so a template that could never be read is
* rejected at registration instead of being listed.
*/
export function compileUriTemplate (template: string): CompiledUriTemplate {
let compiled = compiledUriTemplates.get(template)
if (compiled !== undefined) return compiled

const unsupported = (reason: string) => new Error(`Unsupported URI template '${template}': ${reason}`)

const names: string[] = []
let queryNames: string[] | null = null
let source = '^'
let literalLength = 0
let lastIndex = 0
for (const match of template.matchAll(/\{([^}]*)\}/g)) {
const body = match[1]
const literal = template.slice(lastIndex, match.index)
lastIndex = match.index + match[0].length
literalLength += literal.length

const operator = /^[+#./;?&=,!@|]/.test(body) ? body[0] : ''
const vars = body.slice(operator.length).split(',')
for (const name of vars) {
if (!VARNAME_REGEX.test(name)) {
throw unsupported(`expression '{${body}}' must list variable names made of letters, digits and '_' (modifiers are not supported)`)
}
}

if (queryNames !== null && (literal !== '' || (operator !== '?' && operator !== '&'))) {
throw unsupported('query expressions {?...} and {&...} must come last')
}

switch (operator) {
case '':
case '+':
if (vars.length > 1) {
throw unsupported(`expression '{${body}}' must have a single variable`)
}
source += escapeRegExp(literal) + (operator === '+' ? '([^?#]+)' : '([^/]+)')
names.push(vars[0])
break
case '/':
source += escapeRegExp(literal) + vars.map(() => '(?:/([^/?#]+))?').join('')
names.push(...vars)
break
case '?':
if (queryNames !== null || template.slice(0, match.index).includes('?')) {
throw unsupported(`expression '{${body}}' follows another query component`)
}
source += escapeRegExp(literal)
queryNames = [...vars]
break
case '&':
if (queryNames === null) {
throw unsupported(`expression '{${body}}' must follow a {?...} expression`)
}
queryNames.push(...vars)
break
default:
throw unsupported(`operator '${operator}' is not supported`)
}
}

const tail = template.slice(lastIndex)
if (queryNames !== null && tail !== '') {
throw unsupported('query expressions {?...} and {&...} must come last')
}
source += escapeRegExp(tail) + '$'
literalLength += tail.length

compiled = { regex: new RegExp(source), names, queryNames, literalLength }
compiledUriTemplates.set(template, compiled)
return compiled
}

function escapeRegExp (value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

function decode (raw: string): string {
try {
return decodeURIComponent(raw)
} catch {
return raw
}
}

export function matchUriTemplate (compiled: CompiledUriTemplate, uri: string): Record<string, string> | null {
let path = uri
let query: string | undefined
if (compiled.queryNames !== null) {
const index = uri.indexOf('?')
if (index !== -1) {
path = uri.slice(0, index)
query = uri.slice(index + 1)
}
}

const match = compiled.regex.exec(path)
if (!match) return null

const params: Record<string, string> = {}
compiled.names.forEach((name, i) => {
const raw = match[i + 1]
if (raw !== undefined) params[name] = decode(raw)
})

if (query !== undefined) {
// Order-tolerant: each declared parameter may appear at most once, and
// any parameter the template does not declare means no match
const seen = new Set<string>()
for (const pair of query.split('&')) {
const eq = pair.indexOf('=')
const name = eq === -1 ? pair : pair.slice(0, eq)
if (!compiled.queryNames!.includes(name) || seen.has(name)) return null
seen.add(name)
params[name] = decode(eq === -1 ? '' : pair.slice(eq + 1))
}
}

return params
}
Loading
Loading