Ticker

6/recent/ticker-posts

Desarrollo de un Servicio MCP "filesystem" con Python y FastMCP


En los artículos anteriores se configuró el servidor oficial @modelcontextprotocol/server-filesystem (basado en Node.js) apuntando a la carpeta C:\Users\User\archivos-mcp, y por separado se construyó un servidor mínimo en Python (MiServidorPython) con una única herramienta de saludo. En este artículo combinamos ambos conceptos: construiremos nuestro propio servidor MCP de sistema de archivos, escrito íntegramente en Python con FastMCP, que replica de forma simplificada y con alcance controlado la funcionalidad del servidor oficial de Node.js.

A diferencia de una integración genérica, este servidor tendrá dos responsabilidades explícitas y acotadas:

  1. Lectura de archivos existentes dentro de una carpeta raíz fija: C:\Users\User\archivos-mcp.
  2. Creación de archivos Markdown (.md) dentro de esa misma carpeta.

El servidor se llamará filesystem y expondrá tres herramientas: listar_archivos, leer_archivo y crear_archivo_markdown. Un aspecto de diseño central de este servicio es la restricción de alcance (sandboxing): todas las operaciones deben quedar confinadas dentro de la ruta raíz definida, sin permitir que el modelo lea o escriba archivos fuera de ese directorio, incluso si se le solicita mediante rutas relativas maliciosas (..\..\) o rutas absolutas arbitrarias. Esta validación no es un detalle accesorio: es la diferencia entre un servidor MCP seguro y uno que otorga acceso irrestricto al sistema de archivos del usuario.

1. Instalación de dependencias

Este servidor solo requiere fastmcp, ya que tanto la lectura como la escritura de archivos se implementan con la librería estándar de Python (pathlib), sin dependencias adicionales.

pip install fastmcp

Entorno virtual (recomendado)

cd D:\Hadson.TECH\LEARNING\MCP-Learning
python -m venv .venv
.venv\Scripts\activate
pip install fastmcp

Verificación

python -c "import fastmcp; print(fastmcp.__version__)"

2. Código del servidor (filesystem.py)

import sys
from pathlib import Path

from fastmcp import FastMCP

# ---------------------------------------------------------------------------
# 1. Configuración de la ruta raíz (sandbox)
# ---------------------------------------------------------------------------
BASE_DIR = Path(r"C:\Users\User\archivos-mcp").resolve()

# Nos aseguramos de que la carpeta raíz exista antes de aceptar operaciones.
BASE_DIR.mkdir(parents=True, exist_ok=True)

server = FastMCP("filesystem")


def _resolver_ruta_segura(nombre_relativo: str) -> Path:
    """
    Convierte una ruta relativa proporcionada por el modelo en una ruta
    absoluta, verificando que el resultado permanezca dentro de BASE_DIR.

    Lanza ValueError si la ruta intenta escapar del directorio raíz
    (ej. mediante '..' o rutas absolutas ajenas).
    """
    ruta_solicitada = (BASE_DIR / nombre_relativo).resolve()

    if not ruta_solicitada.is_relative_to(BASE_DIR):
        raise ValueError(
            f"Acceso denegado: '{nombre_relativo}' está fuera del "
            f"directorio permitido ({BASE_DIR})."
        )

    return ruta_solicitada


# ---------------------------------------------------------------------------
# 2. Herramienta: listar archivos y carpetas
# ---------------------------------------------------------------------------
@server.tool()
def listar_archivos(subcarpeta: str = "") -> list[str]:
    """
    Lista los archivos y carpetas dentro de C:\\Users\\User\\archivos-mcp.
    Si se especifica 'subcarpeta', lista el contenido de esa subcarpeta
    relativa al directorio raíz. Devuelve rutas relativas al directorio raíz.
    """
    directorio = _resolver_ruta_segura(subcarpeta)

    if not directorio.exists():
        raise FileNotFoundError(f"La ruta '{subcarpeta}' no existe.")
    if not directorio.is_dir():
        raise NotADirectoryError(f"'{subcarpeta}' no es un directorio.")

    elementos = []
    for item in sorted(directorio.iterdir()):
        etiqueta = "[DIR]" if item.is_dir() else "[FILE]"
        ruta_relativa = item.relative_to(BASE_DIR)
        elementos.append(f"{etiqueta} {ruta_relativa}")

    return elementos


# ---------------------------------------------------------------------------
# 3. Herramienta: leer contenido de un archivo
# ---------------------------------------------------------------------------
@server.tool()
def leer_archivo(nombre_archivo: str) -> str:
    """
    Lee y devuelve el contenido de texto de un archivo ubicado dentro de
    C:\\Users\\User\\archivos-mcp. La ruta debe ser relativa al directorio raíz.
    """
    ruta = _resolver_ruta_segura(nombre_archivo)

    if not ruta.exists():
        raise FileNotFoundError(f"El archivo '{nombre_archivo}' no existe.")
    if not ruta.is_file():
        raise IsADirectoryError(f"'{nombre_archivo}' es un directorio, no un archivo.")

    try:
        return ruta.read_text(encoding="utf-8")
    except UnicodeDecodeError:
        raise ValueError(
            f"'{nombre_archivo}' no parece ser un archivo de texto legible en UTF-8."
        )


# ---------------------------------------------------------------------------
# 4. Herramienta: crear un archivo Markdown
# ---------------------------------------------------------------------------
@server.tool()
def crear_archivo_markdown(nombre_archivo: str, contenido: str) -> str:
    """
    Crea un nuevo archivo Markdown (.md) dentro de C:\\Users\\User\\archivos-mcp
    con el contenido proporcionado. Si 'nombre_archivo' no termina en '.md',
    la extensión se agrega automáticamente. No sobrescribe archivos existentes.
    """
    if not nombre_archivo.endswith(".md"):
        nombre_archivo = f"{nombre_archivo}.md"

    ruta = _resolver_ruta_segura(nombre_archivo)

    if ruta.exists():
        raise FileExistsError(
            f"El archivo '{nombre_archivo}' ya existe. Elige otro nombre."
        )

    # Aseguramos que existan las subcarpetas intermedias, si las hubiera.
    ruta.parent.mkdir(parents=True, exist_ok=True)
    ruta.write_text(contenido, encoding="utf-8")

    return f"Archivo creado correctamente en: {ruta.relative_to(BASE_DIR)}"


# ---------------------------------------------------------------------------
# 5. Punto de entrada
# ---------------------------------------------------------------------------
if __name__ == "__main__":
    print(f"Servidor 'filesystem' operando sobre: {BASE_DIR}", file=sys.stderr)
    server.run(transport="stdio")

3. ¿Cómo funciona este código?

3.1. Definición del directorio raíz y resolve()

BASE_DIR = Path(r"C:\Users\User\archivos-mcp").resolve()

Se utiliza pathlib.Path en lugar de manipulación de strings porque ofrece una API multiplataforma y segura para operaciones de rutas. El prefijo r"" (raw string) evita que Python interprete \U como un carácter de escape Unicode inválido, un error común al trabajar con rutas de Windows.

.resolve() normaliza la ruta a su forma absoluta y canónica —resolviendo símbolos como . o .. si estuvieran presentes— lo cual es indispensable para las comparaciones de seguridad que se hacen más adelante. BASE_DIR.mkdir(parents=True, exist_ok=True) garantiza que la carpeta exista antes de que el servidor acepte cualquier operación, evitando errores en el primer uso.

3.2. La función _resolver_ruta_segura: el mecanismo de sandboxing

Esta es la pieza más importante del diseño desde el punto de vista de seguridad. Cada una de las tres herramientas la invoca antes de tocar el sistema de archivos:

ruta_solicitada = (BASE_DIR / nombre_relativo).resolve()

if not ruta_solicitada.is_relative_to(BASE_DIR):
    raise ValueError(...)

El razonamiento es el siguiente: un modelo de lenguaje (o un usuario malintencionado a través del modelo) podría intentar pasar como parámetro algo como ../../Windows/System32/config o una ruta absoluta ajena (D:\otros-datos\secreto.txt). Si simplemente concatenáramos BASE_DIR / nombre_relativo sin resolver ni validar, Python permitiría que .. "suba" fuera del directorio raíz.

Al llamar .resolve() sobre la ruta combinada, Python colapsa cualquier secuencia .. a su destino final real. Luego, is_relative_to(BASE_DIR) (disponible desde Python 3.9) verifica que esa ruta final siga estando dentro de BASE_DIR. Si no lo está, se lanza una excepción ValueError que FastMCP capturará y devolverá al cliente como un error de herramienta, sin llegar a ejecutar ninguna operación de E/S sobre la ruta no autorizada.

Este patrón —resolver primero, comparar después— es la forma correcta de prevenir ataques de path traversal; comparar strings de rutas sin resolver (por ejemplo, con .startswith()) es una técnica insuficiente y propensa a bypass.

3.3. listar_archivos: exploración del directorio

@server.tool()
def listar_archivos(subcarpeta: str = "") -> list[str]:

El parámetro subcarpeta tiene un valor por defecto (""), lo cual FastMCP traduce en el esquema JSON como un parámetro opcional: el modelo puede invocar la herramienta sin argumentos para listar la raíz, o especificar una subcarpeta relativa para explorar más profundo. directorio.iterdir() recorre el contenido de primer nivel (no recursivo por diseño, para mantener las respuestas acotadas y predecibles), y cada elemento se etiqueta como [DIR] o [FILE] para que el modelo pueda distinguir el tipo sin llamadas adicionales. Las rutas se devuelven relativas a BASE_DIR, nunca absolutas, para no filtrar la estructura de directorios real del sistema del usuario en las respuestas del modelo.

3.4. leer_archivo: lectura con manejo explícito de errores

@server.tool()
def leer_archivo(nombre_archivo: str) -> str:

Además de la validación de sandbox, se contemplan tres estados de fallo explícitos: archivo inexistente (FileNotFoundError), ruta que en realidad es un directorio (IsADirectoryError) y contenido no decodificable como texto UTF-8 (UnicodeDecodeError, capturado y relanzado como ValueError con un mensaje más claro). FastMCP serializa cualquier excepción lanzada dentro de una herramienta como un error de ejecución de herramienta dentro del protocolo MCP, que el cliente (Claude Desktop) muestra de forma legible en la conversación, en lugar de simplemente colapsar el proceso del servidor.

3.5. crear_archivo_markdown: escritura restringida por extensión

@server.tool()
def crear_archivo_markdown(nombre_archivo: str, contenido: str) -> str:

Esta herramienta impone dos reglas de negocio antes de escribir:

  1. Normalización de extensión. Si el nombre proporcionado no termina en .md, se le agrega automáticamente. Esto evita que, por una omisión del modelo o del usuario, se termine creando un archivo de texto plano sin la extensión correcta.
  2. No sobrescritura. Si el archivo de destino ya existe, se lanza FileExistsError en lugar de sobrescribir silenciosamente contenido existente, un principio de diseño defensivo importante cuando la escritura es iniciada por un modelo de lenguaje y no directamente por el usuario.

ruta.parent.mkdir(parents=True, exist_ok=True) permite que el modelo cree el archivo dentro de subcarpetas que aún no existen (por ejemplo, notas/2026/reporte.md), creándolas automáticamente, siempre y cuando la ruta resultante siga pasando la validación de _resolver_ruta_segura.

3.6. Transporte stdio y log a stderr

print(f"Servidor 'filesystem' operando sobre: {BASE_DIR}", file=sys.stderr)
server.run(transport="stdio")

Como se explicó en el artículo anterior, el transporte stdio reserva stdout exclusivamente para los mensajes JSON-RPC del protocolo. Por eso el mensaje de arranque se envía explícitamente a sys.stderr: sirve como confirmación visual en los logs de Claude Desktop (mcp-server-filesystem.log) de que el servidor inició correctamente y sobre qué ruta está operando, sin interferir con el canal de comunicación.

4. ¿Cómo probar la interfaz gráfica de FastMCP?

Desde Visual Studio Code, usando la terminal Bash

  1. Abre la terminal integrada en modo Bash (flecha desplegable del panel de terminal → "Git Bash", o una terminal WSL).

  2. Navega a la carpeta del proyecto y activa el entorno virtual si corresponde:

    cd /d/Hadson.TECH/LEARNING/MCP-Learning
    source .venv/Scripts/activate
    
  3. Ejecuta MCP Inspector apuntando al intérprete del entorno virtual y al script del servidor:

    npx -y @modelcontextprotocol/inspector .venv/Scripts/python.exe filesystem.py
    

    Al igual que en el servidor de saludo, npx -y @modelcontextprotocol/inspector descarga y ejecuta la herramienta oficial de depuración sin instalación global, lanzando server.py como subproceso y comunicándose con él por stdio, exactamente como lo haría Claude Desktop.

  4. Abre en el navegador la URL local que Inspector imprime en la terminal (por ejemplo, http://localhost:6274).

  5. En la pestaña "Tools" de Inspector deberían aparecer las tres herramientas registradas: listar_archivos, leer_archivo y crear_archivo_markdown, cada una con su esquema de parámetros generado automáticamente a partir de los type hints.

  6. Pruebas recomendadas antes de conectar el servidor a Claude Desktop:

    • Ejecutar listar_archivos sin argumentos y confirmar que devuelve el contenido de C:\Users\User\archivos-mcp.
    • Ejecutar crear_archivo_markdown con nombre_archivo = "prueba" y algún contenido, y verificar en el explorador de archivos que se creó prueba.md con la extensión agregada automáticamente.
    • Ejecutar leer_archivo sobre ese mismo archivo y confirmar que el contenido devuelto coincide.
    • Probar deliberadamente un caso de sandbox, por ejemplo leer_archivo con nombre_archivo = "..\\..\\Windows\\win.ini", y confirmar que Inspector muestra el error ValueError: Acceso denegado... en lugar de devolver contenido del sistema.

Si las seis pruebas anteriores se comportan como se describe, el servidor está listo para registrarse en claude_desktop_config.json bajo la clave "filesystem", apuntando command al python.exe del entorno virtual y args a la ruta absoluta de server.py.

Resumen

  • Se construyó un servidor MCP propio en Python, llamado filesystem, que restringe todas sus operaciones a un único directorio raíz (C:\Users\User\archivos-mcp), a diferencia de exponer acceso genérico al sistema de archivos.
  • El servidor expone tres herramientas mediante @server.tool(): listar_archivos (exploración), leer_archivo (lectura de texto) y crear_archivo_markdown (escritura restringida a .md, sin sobrescritura).
  • El componente de seguridad central es _resolver_ruta_segura, que combina Path.resolve() con Path.is_relative_to() para prevenir ataques de path traversal, garantizando que ninguna operación pueda escapar del directorio autorizado incluso si el modelo recibe o construye rutas maliciosas.
  • El manejo de errores es explícito y granular (FileNotFoundError, FileExistsError, IsADirectoryError, ValueError), lo cual permite que Claude Desktop muestre mensajes de fallo claros dentro de la conversación en lugar de que el proceso del servidor colapse silenciosamente.
  • La validación se realiza con MCP Inspector desde una terminal Bash en VS Code, ejecutando npx -y @modelcontextprotocol/inspector contra el intérprete del entorno virtual, probando cada herramienta de forma aislada —incluyendo un intento deliberado de escape de sandbox— antes de integrar el servidor a claude_desktop_config.json.

Con este servidor, Claude Desktop queda en capacidad de leer y generar documentación en Markdown dentro de una carpeta controlada del usuario, manteniendo el principio de menor privilegio que debe regir cualquier herramienta que un modelo de lenguaje pueda invocar sobre el sistema de archivos local.

Publicar un comentario

0 Comentarios