Saltar a contenido

Plantillas de URI y seguridad de rutas

Traducción automática

Esta página se tradujo automáticamente a partir de la documentación en inglés, y la página en inglés es la versión de referencia. Si algo no se lee bien, Traducciones explica cómo avisarnos.

Esta es la referencia de la sintaxis de plantillas de URI que acepta @mcp.resource y de la política de seguridad de rutas que el SDK aplica a los valores extraídos. Para una introducción a qué son los recursos y cuándo usarlos, empieza por Recursos; esta página supone que ya te sientes cómodo declarando un recurso y quieres el conjunto completo de operadores, los ajustes de seguridad o la conexión con la capa de bajo nivel.

La sintaxis de plantillas es RFC 6570. El SDK admite un subconjunto elegido para hacer coincidir las URI entrantes de resources/read, más una capa de seguridad que rechaza los valores que se resolverían fuera del directorio que pretendes servir. Para los detalles a nivel de protocolo (formatos de mensaje, ciclo de vida, paginación) consulta la especificación de recursos de MCP.

El conjunto completo de operadores

El marcador simple, {user_id}, es el que presenta Recursos. Hay cuatro formas de operador más; aquí están en un solo servidor para que puedas verlas una junto a otra:

server.py
from mcp.server import MCPServer

mcp = MCPServer("Bookshop")

BOOKS = {
    "978-0441172719": {"title": "Dune", "author": "Frank Herbert"},
    "978-0553293357": {"title": "Foundation", "author": "Isaac Asimov"},
}

MANUALS = {
    "printing/setup.md": "# Printer setup\n\nLoad paper, then power on.",
    "returns.md": "# Returns policy\n\nThirty days with a receipt.",
}


@mcp.resource("books://{isbn}")
def get_book(isbn: str) -> dict[str, str]:
    """A single book by ISBN."""
    return BOOKS[isbn]


@mcp.resource("orders://{order_id}")
def get_order(order_id: int) -> dict[str, object]:
    """An order by its numeric id."""
    return {"order_id": order_id, "next_order": order_id + 1, "status": "shipped"}


@mcp.resource("manuals://{+path}")
def read_manual(path: str) -> str:
    """A staff manual page. The path keeps its slashes."""
    return MANUALS[path]


@mcp.resource("reviews://{isbn}{?limit,sort}")
def list_reviews(isbn: str, limit: int = 10, sort: str = "newest") -> str:
    """Reviews of a book, optionally limited and sorted."""
    return f"{limit} {sort} reviews of {BOOKS[isbn]['title']}"


@mcp.resource("shelves://browse{/path*}")
def browse_shelf(path: list[str]) -> str:
    """A shelf in the category tree, addressed by segments."""
    return " > ".join(["catalog", *path])

Cada decorador resaltado es una forma distinta de dividir la URI. Las secciones siguientes los recorren de arriba abajo.

Expansión simple: {name}

books://{isbn} es la forma simple, la de todos los días. El marcador se asigna al parámetro isbn, así que un cliente que lee books://978-0441172719 llama a get_book("978-0441172719").

Un {name} simple se detiene en la primera /. books://978/extra no coincide porque la barra después de 978 termina la captura y /extra sobra.

Conversión de tipos

Los valores extraídos llegan como cadenas, pero puedes declarar un tipo más específico y el SDK los convierte. orders://{order_id} llega a una función cuyo parámetro es order_id: int, así que leer orders://12345 llama a get_order(12345), no a get_order("12345"). El handler hace aritmética con él (order_id + 1) sin conversión explícita.

Rutas de varios segmentos: {+name}

Para capturar un valor que contiene barras, usa {+name}. Con manuals://{+path}:

  • manuals://returns.md da path = "returns.md"
  • manuals://printing/setup.md da path = "printing/setup.md"

Recurre a {+name} siempre que el valor sea jerárquico: rutas del sistema de archivos, claves de objetos anidados, rutas de URL que estés redirigiendo como proxy.

Parámetros de consulta: {?a,b,c}

reviews://{isbn}{?limit,sort} pone limit y sort después del ?. La ruta identifica qué libro; la consulta ajusta cómo lo lees.

Los parámetros de consulta se comparan con flexibilidad: el orden no importa, los sobrantes se ignoran y los omitidos caen en los valores por defecto de tu función. Así que reviews://978-0441172719 usa limit=10, sort="newest", y reviews://978-0441172719?sort=top sobrescribe solo sort.

Segmentos de ruta como lista: {/name*}

Si quieres cada segmento de ruta como un elemento separado de una lista en lugar de una sola cadena con barras, usa {/name*}. Con shelves://browse{/path*}, un cliente que lee shelves://browse/fiction/sci-fi llama a browse_shelf(["fiction", "sci-fi"]).

Referencia de plantillas

Los patrones más comunes:

Patrón Entrada de ejemplo Obtienes
{name} alice "alice"
{name} docs/intro.md no coincide (se detiene en /)
{+path} docs/intro.md "docs/intro.md"
{.ext} .json "json"
{/segment} /v2 "v2"
{?key} ?key=value "value"
{?a,b} ?a=1&b=2 "1", "2"
{/path*} /a/b/c ["a", "b", "c"]

Lo que rechaza el analizador

Algunas formas de plantilla se detectan desde el principio en lugar de fallar en la primera solicitud. @mcp.resource analiza la plantilla cuando se ejecuta el decorador, así que ninguna de estas llega nunca a un servidor en ejecución.

UriTemplate.parse() lanza InvalidUriTemplate en estos casos:

  • Dos variables sin nada entre ellas. manuals://{+path}{ext} se rechaza: la comparación no puede saber dónde termina path y dónde empieza ext. Pon un literal entre ellas (manuals://{+path}/{ext}) o usa un operador que aporte su propio delimitador. manuals://{+path}{.ext} se acepta porque {.ext} aporta el . por sí mismo.
  • Más de una variable de varios segmentos. Como máximo una entre {+var}, {#var} o una variable expandida ({/var*}, {.var*}, {;var*}) por plantilla. Dos son intrínsecamente ambiguas: no hay una forma fundamentada de decidir cuál de ellas absorbe un segmento adicional.
  • Los errores de sintaxis habituales: una llave sin cerrar, un nombre de variable usado dos veces o una característica de RFC 6570 que el SDK no admite, como el modificador de prefijo {var:3} o la expansión de consulta {?vars*}.

Además de eso, @mcp.resource lanza ValueError cuando un parámetro del handler está vinculado a una variable de consulta en el tramo final {?...}/{&...} de la plantilla pero no tiene valor por defecto en Python. Esas variables se comparan con flexibilidad (un cliente puede omitir cualquiera de ellas), así que un parámetro sin valor por defecto solo aparecería como un error interno opaco en la primera solicitud que lo omita. reviews://{isbn}{?limit,sort} en el servidor de arriba es la versión bien formada: tanto limit como sort tienen valores por defecto.

Seguridad

Los parámetros de plantilla vienen del cliente. Si llegan a operaciones del sistema de archivos o de base de datos sin comprobar, valores como ../../etc/passwd pueden resolverse fuera del directorio que pretendías servir.

Lo que el SDK comprueba por defecto

Antes de que se ejecute tu handler, el SDK rechaza cualquier parámetro que:

  • escaparía de su directorio de partida mediante componentes ..
  • parezca una ruta absoluta (/etc/passwd, C:\Windows) o una ruta relativa a unidad de Windows (C:foo). Un valor relativo a unidad y un identificador con espacio de nombres como x:y son indistinguibles como cadenas, así que cualquier valor de una sola letra seguida de dos puntos se rechaza por defecto; exime el parámetro si recibe legítimamente ese tipo de valores
  • contenga un byte nulo (\x00)

La comprobación de .. se basa en componentes, no en buscar subcadenas. Valores como v1.0..v2.0 o HEAD~3..HEAD pasan porque ahí .. no es un segmento de ruta independiente.

Estas comprobaciones se aplican al valor decodificado, así que detectan el recorrido de directorios sin importar cómo se codificó en la URI (../etc, ..%2Fetc, %2E%2E/etc, ..%5Cetc, %00: todos se detectan).

Check

Lee manuals://../etc/passwd en el servidor de arriba y la solicitud se rechaza sin más: la comparación de plantillas se detiene en el primer fallo, así que no se prueba ninguna plantilla posterior (potencialmente más permisiva) como alternativa. El cliente ve el mismo error -32602 "Unknown resource" que vería con una URI que no coincide con ninguna plantilla, y read_manual nunca se ejecuta.

Handlers del sistema de archivos: usa safe_join

Las comprobaciones integradas detienen los casos comunes, pero no pueden conocer el límite de tu entorno aislado. Para acceder al sistema de archivos, usa safe_join para resolver la ruta y verificar que se mantiene dentro de tu directorio base:

server.py
from pathlib import Path

from mcp.server import MCPServer
from mcp.shared.path_security import safe_join

mcp = MCPServer("Bookshop")

DOCS_ROOT = Path("./manuals")


@mcp.resource("manuals://{+path}")
def read_manual(path: str) -> str:
    """A staff manual page, served from a directory on disk."""
    return safe_join(DOCS_ROOT, path).read_text()

safe_join detecta escapes mediante enlaces simbólicos, secuencias .. y trucos con rutas absolutas que una simple comprobación de cadenas pasaría por alto. Si la ruta resuelta escapa de DOCS_ROOT, lanza PathEscapeError, que le llega al cliente como un ResourceError.

Cuando los valores por defecto estorban

A veces las comprobaciones bloquean valores legítimos. Una herramienta de importación de catálogos podría recibir intencionadamente una ruta absoluta, o un parámetro podría ser una referencia relativa como ../sibling que tu handler interpreta con seguridad sin tocar el sistema de archivos. Exime ese parámetro o relaja la política para todo el servidor:

server.py
from mcp.server import MCPServer
from mcp.server.mcpserver import ResourceSecurity

mcp = MCPServer("Bookshop")


@mcp.resource(
    "imports://preview/{+source}",
    security=ResourceSecurity(exempt_params={"source"}),
)
def preview_import(source: str) -> str:
    """Preview a catalog import. `source` may be an absolute path."""
    return f"Would import from {source}"


relaxed = MCPServer(
    "Bookshop",
    resource_security=ResourceSecurity(reject_path_traversal=False),
)


@relaxed.resource("imports://preview/{+source}")
def preview_import_relaxed(source: str) -> str:
    """The server-wide flag exempts every resource on `relaxed`."""
    return f"Would import from {source}"
  • security=ResourceSecurity(exempt_params={"source"}) en el decorador omite las comprobaciones para ese único parámetro en ese único recurso. El resto del servidor mantiene la política por defecto.
  • resource_security= en el constructor de MCPServer fija el valor por defecto para todos los recursos. Aquí relaxed desactiva por completo la comprobación de ...

Las comprobaciones configurables:

Ajuste Por defecto Qué hace
reject_path_traversal True Rechaza secuencias .. que escapan del directorio de partida
reject_absolute_paths True Rechaza /foo, C:\foo, rutas UNC y la ruta relativa a unidad C:foo (también detecta x:y)
reject_null_bytes True Rechaza valores que contienen \x00
exempt_params vacío Nombres de parámetros para los que se omiten las comprobaciones

Estas comprobaciones son un prefiltro heurístico; para el acceso al sistema de archivos, safe_join sigue siendo el límite de contención.

Tip

Si tu handler no puede satisfacer la solicitud (el archivo no existe, el id es desconocido), lanza una excepción. El SDK la convierte en una respuesta de error. Consulta Manejo de errores para ver la diferencia entre un error de protocolo y un error de herramienta.

Recursos en el Server de bajo nivel

Si construyes sobre el Server de bajo nivel (consulta El Server de bajo nivel), registras directamente los handlers para los métodos de protocolo resources/list y resources/read. No hay decorador; devuelves tú mismo los tipos del protocolo.

Recursos estáticos

Para URI fijas, mantén un registro y despacha por coincidencia exacta:

server.py
from mcp.server import Server, ServerRequestContext
from mcp.types import (
    ListResourcesResult,
    PaginatedRequestParams,
    ReadResourceRequestParams,
    ReadResourceResult,
    Resource,
    TextResourceContents,
)

RESOURCES = {
    "config://shop": '{"currency": "USD", "tax_rate": 0.08}',
    "status://health": "ok",
}


async def list_resources(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListResourcesResult:
    return ListResourcesResult(resources=[Resource(name=uri, uri=uri) for uri in RESOURCES])


async def read_resource(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult:
    if (text := RESOURCES.get(params.uri)) is not None:
        return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text=text)])
    raise ValueError(f"Unknown resource: {params.uri}")


server = Server("Bookshop", on_list_resources=list_resources, on_read_resource=read_resource)

El handler de listado les dice a los clientes qué hay disponible; el handler de lectura sirve el contenido. Comprueba primero tu registro, pasa a las plantillas (más abajo) si tienes alguna y luego lanza una excepción para cualquier otra cosa.

Plantillas

El motor de plantillas que usa MCPServer vive en mcp.shared.uri_template y funciona por sí solo. Obtienes el mismo análisis y la misma comparación; el enrutamiento y la política de seguridad los conectas tú mismo.

server.py
from mcp.server import Server, ServerRequestContext
from mcp.shared.path_security import contains_path_traversal, is_absolute_path
from mcp.shared.uri_template import UriTemplate
from mcp.types import (
    ListResourceTemplatesResult,
    PaginatedRequestParams,
    ReadResourceRequestParams,
    ReadResourceResult,
    ResourceTemplate,
    TextResourceContents,
)

TEMPLATES = {
    "manuals": UriTemplate.parse("manuals://{+path}"),
    "books": UriTemplate.parse("books://{isbn}"),
}

MANUALS = {"printing/setup.md": "# Printer setup", "returns.md": "# Returns policy"}
BOOKS = {"978-0441172719": "Dune by Frank Herbert"}


def read_manual_safely(path: str) -> str:
    if contains_path_traversal(path) or is_absolute_path(path):
        raise ValueError("rejected")
    return MANUALS[path]


async def read_resource(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult:
    if (matched := TEMPLATES["manuals"].match(params.uri)) is not None:
        text = read_manual_safely(str(matched["path"]))
        return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text=text)])

    if (matched := TEMPLATES["books"].match(params.uri)) is not None:
        text = BOOKS[str(matched["isbn"])]
        return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text=text)])

    raise ValueError(f"Unknown resource: {params.uri}")


async def list_resource_templates(
    ctx: ServerRequestContext, params: PaginatedRequestParams | None
) -> ListResourceTemplatesResult:
    return ListResourceTemplatesResult(
        resource_templates=[
            ResourceTemplate(name=name, uri_template=str(template)) for name, template in TEMPLATES.items()
        ]
    )


server = Server(
    "Bookshop",
    on_read_resource=read_resource,
    on_list_resource_templates=list_resource_templates,
)

En las líneas resaltadas ocurren tres cosas:

  • Analiza una vez, compara en cada solicitud. UriTemplate.parse() construye la plantilla; template.match(uri) devuelve las variables extraídas como un dict, o None si la URI no encaja. La decodificación de URL ocurre dentro de match(); los valores decodificados se devuelven tal cual, sin validación de seguridad de rutas. Los valores salen como cadenas: conviértelos tú mismo (int(matched["id"]), Path(matched["path"])).
  • Aplica tú mismo las comprobaciones de seguridad. Las comprobaciones de .. y de rutas absolutas que MCPServer ejecuta por defecto viven en mcp.shared.path_security. read_manual_safely las llama antes de tocar MANUALS. Si un parámetro no es una ruta del sistema de archivos (un ISBN, una consulta de búsqueda), omite las comprobaciones para ese valor: controlas la política por handler en lugar de hacerlo mediante un objeto de configuración.
  • Lista las plantillas desde la misma fuente. Los clientes descubren las plantillas mediante resources/templates/list. str(template) devuelve la cadena original de la plantilla, así que el listado y el comparador comparten una única fuente de verdad.

Resumen

  • {name} coincide con un segmento; {+name} conserva las barras; {?a,b} toma de la cadena de consulta; {/name*} divide los segmentos en una lista.
  • Dos variables sin nada entre ellas, o una segunda variable de varios segmentos, se rechazan al analizar. Un parámetro vinculado a una variable de consulta final {?...}/{&...} debe declarar un valor por defecto en Python.
  • Anota el parámetro (order_id: int) y el SDK convierte.
  • La política de seguridad por defecto rechaza .., rutas absolutas y bytes nulos antes de que se ejecute tu handler; sobrescríbela por recurso con security=ResourceSecurity(...) o para todo el servidor con resource_security=.
  • Para el acceso al sistema de archivos, safe_join es el límite de contención.
  • En el Server de bajo nivel, analiza con UriTemplate.parse(), compara con .match() y aplica mcp.shared.path_security tú mismo.