Crea tu primer servidor MCP con Python: herramientas para consultar documentos Markdown
Inicia sesión para descargarAprende a construir un servidor MCP con Python que permite listar, buscar y leer notas Markdown locales. Descubre cómo funcionan las herramientas MCP, cómo validar entradas y cómo comprobar el proyecto con …
Contenido del tutorial ⌄
- Convierte tus notas Markdown en herramientas MCP
- ¿Qué construirás y qué aprenderás?
- Al finalizar podrás
- Antes de empezar: conocimientos y herramientas
- Bibliotecas que utilizaremos
- ¿Qué es MCP y qué problema resuelve?
- ¿Por qué utilizar un protocolo compartido?
- MCP no es un modelo de inteligencia artificial
- Cliente, servidor, transporte y herramientas: quién hace qué
- Dos maneras de probar nuestro mismo servidor
- ¿Qué sucede al solicitar una búsqueda?
- Del cliente a las notas: recorrido de una solicitud MCP
- ¿Qué sucede exactamente?
- La llamada en el código real
- ¿Qué respuesta recibe el cliente?
- Cómo está organizado el proyecto
- Responsabilidad de los archivos principales
- Archivos de apoyo: datos, dependencias y pruebas
- ¿Qué sigue?
- Dentro de notas_core.py: la lógica que consulta los documentos
- Importaciones y límites de recursos
- Validar nombres con una expresión regular
- Descomponer NAME_PATTERN
- NotesError: errores que el programa sabe explicar
- Inicializar NotesStore sin fijar una carpeta en el código
- Comprobar que la carpeta está disponible
- Una protección útil, con límites
- Enumerar archivos con os.scandir
- Qué hace _names()
- Por qué listar_notas() es tan corto
- Leer una nota de forma limitada con leer_nota()
- Explicación del método, paso a paso
- Control de errores y liberación de archivos
- Buscar contenido con buscar_notas()
- Qué debe aceptar una consulta
- Cómo encuentra y devuelve las coincidencias
- Resumen del núcleo: tres métodos, una política de acceso
- Cómo sabemos que este comportamiento se comprueba
- Siguiente paso: conectar el núcleo con MCP
- Construir el servidor MCP: de funciones Python a herramientas
- Las responsabilidades de nuestro servidor
- Importaciones: conectar el SDK MCP con nuestro núcleo
- ¿Dónde se encuentran las notas?
- ¿Qué significa READ_ONLY?
- Crear el servidor con create_server()
- Dos objetos con responsabilidades diferentes
- ¿Por qué permitir cambiar root?
- Los decoradores convierten funciones en herramientas MCP
- ¿De dónde sale la variable store?
- Primera herramienta MCP: listar_notas()
- Explicación del código
- Segunda herramienta MCP: buscar_notas()
- Qué hace cada capa
- Tercera herramienta MCP: leer_nota()
- ¿Quién comprueba la seguridad del archivo?
- Errores de herramienta y ejecución del servidor
- ¿Por qué transformar la excepción?
- El significado de raise ... from exc
- Cómo arranca realmente el servidor
- Comprobar que el servidor expone exactamente tres herramientas
- ¿Qué podemos comprobar mediante el cliente?
- Lo que este servidor no hace
- Siguiente paso: utilizar el servidor desde Python
- Crear un cliente MCP con Python: del servidor a las llamadas reales
- Qué aprenderemos
- Las importaciones de cliente_demo.py
- ¿Por qué importamos directamente el servidor?
- Programación asíncrona: comprender async y await
- El papel de asyncio.run()
- Establecer una conexión con Client
- Descomponer la instrucción
- Invocar herramientas MCP mediante call_tool()
- Las tres solicitudes del ejemplo
- ¿Qué devuelve call_tool?
- Comprobar errores e interpretar los resultados del cliente
- Primero: ¿la herramienta indicó un error?
- Después: acceder a los datos estructurados
- Ejecutar nuestro cliente MCP desde la terminal
- ¿Qué instalaremos?
- Windows: PowerShell
- Interpretar la salida de nuestro cliente MCP
- Qué significa cada resultado
- ¿Por qué aparecen caracteres como \n?
- Resumen: nuestro primer cliente MCP está funcionando
- Lo que conviene recordar
- Siguiente paso: comprobar el programa con pruebas automatizadas
- Probar nuestro servidor MCP con pytest: comprobar antes de confiar
- ¿Qué queremos comprobar?
- Pruebas unitarias e integración: dos niveles de comprobación
- ¿Por qué necesitamos ambas?
- Instalar pytest y preparar el entorno de pruebas
- ¿Qué significa cada dependencia?
Convierte tus notas Markdown en herramientas MCP
Imagina que una aplicación pudiera consultar tus apuntes locales usando funciones bien definidas, sin recibir permiso para modificar los archivos. En este tutorial construiremos exactamente eso con Python.
Desarrollaremos un servidor del Model Context Protocol (MCP) capaz de listar notas, buscar contenido y leer un documento por su nombre. Después comprobaremos cada operación con un cliente Python, pruebas automatizadas y MCP Inspector.
La idea central: MCP no es un modelo de inteligencia artificial. Es un protocolo que permite a un cliente descubrir e invocar las herramientas que ofrece un servidor.
¿Qué construirás y qué aprenderás?
El resultado será un pequeño sistema de notas Markdown con tres herramientas MCP. Todas trabajan sobre una carpeta local controlada por el usuario.
listar_notas()
Devuelve los nombres de las notas Markdown permitidas.
buscar_notas(consulta)
Busca una palabra o expresión en el contenido de las notas.
leer_nota(nombre)
Lee una nota por nombre y rechaza rutas que no están permitidas.
Al finalizar podrás
- Explicar qué son un servidor, un cliente, un transporte y una herramienta MCP.
- Separar la lógica de lectura de archivos del código del protocolo.
- Validar nombres, limitar tamaños y gestionar errores esperados.
- Invocar herramientas MCP desde un cliente Python sin utilizar un LLM.
-
Ejecutar pruebas con
pytesty comprobar el servidor mediante MCP Inspector.
Objetivo práctico: entender por qué funciona cada componente, no solamente copiar un proyecto terminado.
Antes de empezar: conocimientos y herramientas
Este tutorial está pensado para quienes conocen los fundamentos de Python y quieren dar sus primeros pasos con MCP. Trabajaremos desde la terminal y utilizaremos una carpeta de notas de demostración.
Necesitas para programar
- Python 3.11, 3.12 o 3.13.
- Git y acceso a una terminal.
- Conocimientos básicos de funciones, clases y entornos virtuales.
- Conexión a Internet para instalar las dependencias.
Para las pruebas visuales
- Node.js con
npx. -
uvpara iniciar el entorno de MCP Inspector. - Un navegador moderno.
- Windows PowerShell, Linux o macOS.
Bibliotecas que utilizaremos
Trabajaremos con mcp[cli]==2.3.0
y pytest==9.0.2.
En la sección de instalación aprenderás a crear
el entorno y a instalarlas desde los archivos
de requisitos.
No necesitas una API de pago. El servidor y el cliente de demostración funcionan localmente, sin claves de un proveedor de IA.
En la siguiente sección veremos la arquitectura y el recorrido de una solicitud antes de empezar a escribir código.
¿Qué es MCP y qué problema resuelve?
Un programa puede tener información útil y otro puede necesitarla. ¿Cómo conectarlos sin inventar una integración distinta para cada herramienta? Esa es la pregunta que nos ayuda a responder MCP.
Model Context Protocol (MCP) es un protocolo abierto para comunicar aplicaciones cliente con servidores que ofrecen capacidades, como consultar información o ejecutar herramientas. Define una manera común de descubrir esas capacidades, enviar solicitudes y recibir resultados.
En este proyecto, la información son archivos .md de una
carpeta local y las capacidades son tres funciones Python: listar, buscar
y leer notas. MCP nos permite presentar esas funciones a un cliente
sin acoplarlo a los detalles internos del sistema de archivos.
¿Por qué utilizar un protocolo compartido?
Supón que tienes varias aplicaciones que necesitan consultar las mismas notas. Si cada una se comunica con el programa de una forma diferente, deberás mantener varias integraciones. MCP establece un contrato compartido entre clientes y servidores compatibles.
Descubrir
El cliente puede consultar qué herramientas expone el servidor y qué argumentos esperan.
Invocar
El cliente llama una herramienta por su nombre y proporciona argumentos estructurados.
Responder
El servidor ejecuta su lógica y devuelve un resultado o comunica un error.
MCP no es un modelo de inteligencia artificial
Un modelo de IA puede beneficiarse de herramientas MCP cuando una
aplicación compatible se las proporciona, pero MCP no exige usar un LLM.
Nuestro archivo cliente_demo.py llama las herramientas directamente
desde Python, sin claves de API ni servicios de IA de pago.
Qué aprenderás a distinguir: MCP define la comunicación;
el SDK de Python nos ayuda a implementarla; y notas_core.py
contiene las reglas reales para acceder a los documentos. Son tres
responsabilidades distintas.
Cliente, servidor, transporte y herramientas: quién hace qué
Para entender el funcionamiento de MCP conviene separar los participantes de la comunicación. En algunas aplicaciones hay un host (por ejemplo, una aplicación que integra clientes MCP); cada cliente mantiene la conexión con un servidor. En nuestro tutorial podemos trabajar directamente con un cliente Python sin construir una aplicación host completa.
Cliente MCP
Es el componente que establece la conexión, descubre herramientas
y envía solicitudes. En el repositorio lo encontrarás en
cliente_demo.py, mediante la clase Client.
Servidor MCP
Publica las capacidades disponibles y atiende las llamadas.
En server.py lo construimos con MCPServer
y registramos funciones mediante @server.tool.
Herramientas
Son operaciones concretas que el servidor ofrece al cliente.
Las nuestras se llaman listar_notas,
buscar_notas y leer_nota.
Transporte
Es el mecanismo que lleva los mensajes entre cliente y servidor.
Este ejemplo permite ejecutarlos localmente sin red; al iniciar
server.py como programa, el servidor utiliza stdio.
Dos maneras de probar nuestro mismo servidor
Cliente de demostración: conexión en memoria
cliente_demo.py importa mcp desde
server.py y ejecuta Client(mcp).
Así podemos invocar herramientas en el mismo entorno Python,
sin iniciar manualmente otro proceso.
MCP Inspector: comunicación por STDIO
Con mcp dev server.py abriremos Inspector.
El entorno de desarrollo puede iniciar el servidor como un proceso
y comunicarse con él mediante la entrada y salida estándar.
¿Qué sucede al solicitar una búsqueda?
Pensemos en una llamada real: el cliente invoca
buscar_notas con el argumento consulta="permisos".
- El cliente identifica la herramienta y envía el argumento
consulta. - El servidor MCP recibe la invocación y ejecuta su función registrada.
- La función delega la búsqueda en
NotesStore.buscar_notas, dentro denotas_core.py. - El núcleo valida el término y examina el contenido de las notas autorizadas.
- El resultado vuelve al cliente, que obtiene una lista con
seguridad.md.
Un detalle de seguridad: declarar una herramienta
como de solo lectura mediante read_only_hint=True
informa de su intención, pero no impone permisos por sí mismo.
La restricción efectiva depende de las operaciones que implementamos
y de las validaciones de NotesStore.
Ahora que conocemos los componentes, el siguiente bloque mostrará su relación en un diagrama de arquitectura.
Del cliente a las notas: recorrido de una solicitud MCP
Ya conocemos los componentes y hemos visto su arquitectura
en la figura 1. Ahora seguiremos una operación real del
proyecto: buscar la palabra permisos dentro
de nuestras notas Markdown.
¿Qué sucede exactamente?
-
El cliente prepara la petición.
cliente_demo.pyllama abuscar_notascon el argumento{"consulta": "permisos"}. -
MCP identifica la herramienta.
El servidor creado en
server.pytiene registrada una función con ese nombre y recibe el argumentoconsulta. -
El servidor delega el trabajo.
La función MCP llama a
store.buscar_notas(consulta). La lógica de búsqueda no está implementada dentro del protocolo. - NotesStore valida y busca. Comprueba el término, enumera los archivos permitidos y busca el texto en las notas que puede leer correctamente.
-
El resultado regresa al cliente.
La búsqueda encuentra
seguridad.md, cuyo contenido incluye la palabra «permisos».
Idea clave:
MCP comunica la petición y el resultado,
pero la validación de los archivos pertenece
a notas_core.py.
Esta separación permite probar el núcleo
sin iniciar un servidor MCP.
La llamada en el código real
En cliente_demo.py hay un bucle que
invoca las tres herramientas. La segunda llamada
es precisamente la búsqueda que acabamos de recorrer.
El siguiente bloque es un fragmento auténtico
de ese archivo.
for nombre, argumentos in (
("listar_notas", {}),
("buscar_notas", {"consulta": "permisos"}),
("leer_nota", {"nombre": "python.md"}),
):
result = await client.call_tool(nombre, argumentos)
if result.is_error:
raise RuntimeError(f"Error en {nombre}: {result.content}")
print(f"{nombre}: {result.structured_content}")
¿Qué respuesta recibe el cliente?
Para nuestra búsqueda, el cliente obtiene una estructura
equivalente a
{"result": ["seguridad.md"]}.
El elemento encontrado es el
nombre de la nota,
no una copia de toda la carpeta.
El ejemplo imprime los resultados mediante
result.structured_content.
En el fragmento anterior, la variable
client ya está conectada dentro
de un contexto asíncrono; estudiaremos esa
parte cuando lleguemos a
cliente_demo.py.
¿Y si algo sale mal?
Por ejemplo, intentar leer
../secreto.md
provoca un error de validación.
NotesStore genera
NotesError y
server.py lo transforma en un
error de herramienta MCP mediante
ToolError.
No se devuelve el archivo solicitado.
Nota: el cliente de demostración se conecta al servidor en memoria. MCP Inspector utiliza la alternativa STDIO. Ambos invocan las mismas herramientas, sin necesidad de un modelo de IA.
Cómo está organizado el proyecto
Antes de examinar las funciones línea por línea, debemos saber dónde vive cada responsabilidad. El repositorio separa el protocolo MCP, la lógica de lectura de notas y el cliente de demostración.
Esta división no es solamente una cuestión de orden: hace que el código resulte más sencillo de comprender, probar y mantener. A continuación vemos el árbol de archivos de la versión del proyecto utilizada en este tutorial.
mcp-python-secure-notes/
├── server.py
├── notas_core.py
├── cliente_demo.py
├── notas/
│ ├── python.md
│ └── seguridad.md
├── tests/
│ ├── test_notas_core.py
│ └── test_mcp.py
├── requirements.txt
├── requirements-dev.txt
├── README.md
├── LICENSE
├── .gitignore
└── .github/
└── workflows/
└── ci.yml
Responsabilidad de los archivos principales
notas_core.py
Implementa NotesStore: valida
nombres y consultas, enumera notas y realiza
lecturas limitadas de archivos Markdown.
No necesita importar MCP.
server.py
Crea MCPServer, registra las tres
herramientas y conecta sus llamadas con
los métodos de NotesStore.
cliente_demo.py
Utiliza Client para conectarse en memoria,
invocar las herramientas y mostrar sus
resultados estructurados.
Archivos de apoyo: datos, dependencias y pruebas
Datos y dependencias
notas/ contiene los dos documentos
de ejemplo. requirements.txt instala
el SDK MCP y requirements-dev.txt
añade pytest para el desarrollo.
Calidad y documentación
tests/ contiene pruebas del núcleo
y del protocolo.
.github/workflows/ci.yml
automatiza su ejecución.
README.md explica el uso y
LICENSE contiene la licencia MIT.
El principio que debemos recordar:
el servidor MCP no debería encargarse de verificar
cada archivo por su cuenta.
Esa responsabilidad vive en
NotesStore;
el servidor expone sus operaciones
mediante un protocolo común.
¿Qué sigue?
Ya entendemos qué es MCP, cómo se comunican
los componentes y cómo se organiza este proyecto.
En la siguiente fase construiremos y analizaremos
el núcleo notas_core.py,
desde sus constantes hasta las operaciones de listado,
búsqueda y lectura segura de notas.
Dentro de notas_core.py: la lógica que consulta los documentos
MCP hace posible que un cliente invoque herramientas, pero la seguridad de los archivos no aparece por arte de magia: debemos programar las reglas que autorizan cada operación.
El módulo notas_core.py concentra esas reglas
en la clase NotesStore. Está escrito con
la biblioteca estándar de Python y no necesita importar
el SDK MCP.
Importaciones y límites de recursos
os sirve para inspeccionar y abrir archivos,
re para validar los nombres,
stat para reconocer archivos regulares
y Path para manipular rutas.
Las constantes definen límites explícitos antes de
consultar datos locales.
from __future__ import annotations
import os
import re
import stat
from pathlib import Path
MAX_BYTES = 64 * 1024
MAX_FILES = 100
MAX_RESULTS = 10
NAME_PATTERN = re.compile(r"[A-Za-z0-9_-]{1,60}\.md\Z", re.ASCII)
MAX_BYTES
Cada nota legible puede contener como máximo 65.536 bytes. No son necesariamente 65.536 caracteres: UTF-8 usa varios bytes para algunos símbolos.
MAX_FILES
Permite enumerar hasta cien archivos regulares con nombres admitidos; un archivo válido adicional provoca un error.
MAX_RESULTS
Una búsqueda devuelve, como máximo, diez nombres coincidentes. Para encontrarlos puede revisar más de diez notas.
Decisión de ingeniería: los límites no tienen por qué ser iguales en todos los sistemas. Aquí son valores pequeños adecuados para un ejemplo local; facilitan comprobar comportamientos predecibles.
Validar nombres con una expresión regular
Si un cliente pide leer un documento, ¿debemos aceptar
una ruta como ../secreto.md?
En este proyecto, no. La herramienta espera un
nombre simple de archivo,
sin directorios, terminado en .md.
Descomponer NAME_PATTERN
-
[A-Za-z0-9_-]: letras ASCII, números, guion y guion bajo. -
{1,60}: entre uno y sesenta caracteres antes de la extensión. -
\.md: punto literal y extensión minúscula.md. -
\Z: final de la cadena; junto confullmatchexige un nombre completo válido. -
re.ASCII: expresa la intención de trabajar con un conjunto ASCII restringido.
Ejemplos admitidos
python.md,
seguridad.md,
tema_1.md y
api-v2.md.
Ejemplos rechazados
../secreto.md,
carpeta/nota.md,
hola.txt,
nota privada.md y
éxito.md.
Dentro de leer_nota el programa utiliza
isinstance(nombre, str) y
NAME_PATTERN.fullmatch(nombre):
si el valor no es texto o no coincide completamente
con el patrón, genera un error antes de construir la ruta.
Alcance real: esta validación impide rutas arbitrarias suministradas como nombres, pero no sustituye un aislamiento completo frente a modificaciones concurrentes del directorio.
NAME_PATTERN = re.compile(r"[A-Za-z0-9_-]{1,60}\.md\Z", re.ASCII)
NotesError: errores que el programa sabe explicar
Un nombre inválido, una nota inexistente o un documento
demasiado grande son problemas que podemos prever.
En lugar de permitir que el usuario reciba mensajes
inesperados del sistema operativo, el núcleo emplea
una excepción propia llamada NotesError.
La clase hereda de ValueError.
No necesita métodos adicionales: su nombre permite
distinguir los errores recuperables de consulta frente
a problemas ajenos a la lógica de notas.
La conversión a ToolError ocurre
en la capa MCP, que estudiaremos en la Fase D.
Por eso notas_core.py puede seguir
siendo independiente del protocolo.
class NotesError(ValueError):
"""Error recuperable al consultar notas."""
Inicializar NotesStore sin fijar una carpeta en el código
NotesStore representa una colección
de notas locales. Su constructor recibe
root y lo transforma en
Path. Así, el mismo código sirve
para la carpeta notas/ y para
los directorios temporales que usan las
pruebas automatizadas.
Observa que __init__
no abre ni valida todavía
la carpeta: únicamente guarda la ruta.
La comprobación se hace al llamar a un método
de lectura, búsqueda o listado.
Principio de diseño:
recibir la dependencia como argumento facilita
escribir pruebas sin tocar los documentos reales.
En el repositorio, server.py
construye el almacén con la carpeta local de notas.
class NotesStore:
"""Acceso no recursivo a una carpeta local confiable."""
def __init__(self, root: Path):
self.root = Path(root)
Comprobar que la carpeta está disponible
Antes de enumerar o leer archivos,
_check_root() evalúa la ruta recibida.
is_symlink() comprueba si la entrada
final es un enlace simbólico y
is_dir() verifica que existe
un directorio.
Si la carpeta falta o la entrada es un enlace
simbólico, se lanza NotesError
con el mensaje «La carpeta de notas no está disponible».
Una protección útil, con límites
- Se rechaza una carpeta raíz que sea directamente un enlace simbólico.
- No se presupone que una ruta inexistente debe crearse automáticamente.
- No se eliminan todas las carreras entre comprobación y uso ni se validan todos los directorios antecesores contra cambios simultáneos.
Supuesto fundamental: la carpeta de notas debe ser confiable y permanecer bajo control del usuario. Esta implementación es un ejemplo local de solo lectura, no un sandbox para archivos que controla un atacante.
def _check_root(self) -> None:
if self.root.is_symlink() or not self.root.is_dir():
raise NotesError("La carpeta de notas no está disponible")
Enumerar archivos con os.scandir
No toda entrada de una carpeta es una nota válida:
puede ser un subdirectorio, un archivo con otra extensión
o un enlace simbólico. La función privada
_names() recorre las entradas y acepta
únicamente archivos regulares con nombres que cumplen
NAME_PATTERN.
Qué hace _names()
-
Comprueba la carpeta con
_check_root(). -
Itera sus entradas con
os.scandir(self.root), sin recorrer subdirectorios. -
Filtra nombres mediante
NAME_PATTERN.fullmatch(entry.name). -
Usa
entry.is_file(follow_symlinks=False)para no seguir enlaces simbólicos. - Rechaza una entrada válida adicional cuando ya reunió cien notas permitidas.
-
Devuelve
sorted(names), una lista alfabética de nombres, no de rutas absolutas.
Un error del sistema operativo durante la enumeración
se convierte en NotesError.
Los archivos que no cumplen la política
sencillamente se omiten.
def _names(self) -> list[str]:
self._check_root()
try:
names = []
# scandir itera progresivamente; Path.iterdir puede materializar
# toda la carpeta antes de aplicar MAX_FILES en algunas versiones.
with os.scandir(self.root) as entries:
for entry in entries:
if not NAME_PATTERN.fullmatch(entry.name):
continue
if entry.is_file(follow_symlinks=False):
if len(names) >= MAX_FILES:
raise NotesError(f"Hay más de {MAX_FILES} notas permitidas")
names.append(entry.name)
except OSError as exc:
raise NotesError("No se pudo enumerar las notas") from exc
return sorted(names)
Por qué listar_notas() es tan corto
La API pública no necesita repetir los filtros:
devuelve el resultado de _names().
Este método compartido también se utilizará en las
búsquedas; eso evita implementar dos políticas
diferentes para decidir qué nombres se aceptan.
Con los documentos de ejemplo:
listar_notas() devuelve
['python.md', 'seguridad.md'].
El listado no abre cada documento, así que un archivo demasiado grande o con codificación incorrecta puede aparecer como nombre. Su contenido se validará al intentar leerlo.
def listar_notas(self) -> list[str]:
"""No devuelve rutas completas ni archivos fuera de la carpeta."""
return self._names()
Leer una nota de forma limitada con leer_nota()
Este es el método más importante del núcleo: toma un nombre proporcionado por el cliente y realiza una lectura real de archivos. No basta con validar la extensión; también hay que comprobar el tipo de entrada, el tamaño y la codificación.
El siguiente bloque reproduce el método
completo de la versión verificada en GitHub.
Aunque aparece sin las importaciones y la clase
que lo rodean, el contenido corresponde exactamente
a notas_core.py.
def leer_nota(self, nombre: str) -> str:
"""Lee hasta 64 KiB de una nota regular y UTF-8."""
if not isinstance(nombre, str) or not NAME_PATTERN.fullmatch(nombre):
raise NotesError("Nombre inválido: letras, números, _ o - y termina en .md")
self._check_root()
path = self.root / nombre
try:
info = path.lstat()
if not stat.S_ISREG(info.st_mode):
raise NotesError("La nota no existe o no es un archivo regular")
if info.st_size > MAX_BYTES:
raise NotesError("La nota supera el límite de 64 KiB")
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0)
flags |= getattr(os, "O_CLOEXEC", 0)
flags |= getattr(os, "O_NOFOLLOW", 0)
flags |= getattr(os, "O_NONBLOCK", 0)
fd = os.open(path, flags)
try:
opened = os.fstat(fd)
if not stat.S_ISREG(opened.st_mode):
raise NotesError("La nota no es un archivo regular")
if opened.st_size > MAX_BYTES:
raise NotesError("La nota supera el límite de 64 KiB")
with os.fdopen(fd, "rb") as stream:
fd = -1
data = stream.read(MAX_BYTES + 1)
finally:
if fd >= 0:
os.close(fd)
if len(data) > MAX_BYTES:
raise NotesError("La nota supera el límite de 64 KiB")
return data.decode("utf-8").replace("\r\n", "\n")
except FileNotFoundError as exc:
raise NotesError("La nota no existe") from exc
except UnicodeError as exc:
raise NotesError("La nota no tiene codificación UTF-8 válida") from exc
except OSError as exc:
raise NotesError("No se pudo leer la nota autorizada") from exc
Explicación del método, paso a paso
-
Entrada:
nombredebe ser una cadena y cumplir el patrón. Esto rechaza rutas como../secreto.mdantes de consultar el archivo. -
Carpeta:
_check_root()confirma que el directorio está disponible. Después se construyepath = self.root / nombre. -
Inspección inicial:
path.lstat()examina la entrada sin seguir su último enlace simbólico.stat.S_ISREG(info.st_mode)exige un archivo regular. -
Tamaño inicial:
info.st_sizeimpide intentar leer notas que ya superan 64 KiB. -
Apertura:
os.O_RDONLYestablece lectura solamente; las opcionesO_BINARY,O_CLOEXEC,O_NOFOLLOWyO_NONBLOCKse usan cuando existen en el sistema operativo. -
Segunda comprobación:
os.fstat(fd)inspecciona el archivo efectivamente abierto para verificar de nuevo tipo y tamaño. -
Lectura:
read(MAX_BYTES + 1)lee como máximo 65.537 bytes. Ese byte adicional permite detectar un archivo que excedió el límite. -
Resultado:
decode('utf-8')convierte bytes a texto yreplace('\r\n', '\n')unifica los saltos de línea habituales de Windows.
Control de errores y liberación de archivos
El administrador de contexto with
y el bloque finally evitan dejar
abierto el descriptor.
Los errores de archivo inexistente,
codificación UTF-8 inválida y sistema operativo
se transforman en NotesError
con mensajes apropiados.
Caso válido
leer_nota('python.md')
devuelve el texto del documento de demostración.
Caso rechazado
leer_nota('../secreto.md')
produce NotesError:
es una ruta, no un nombre permitido.
Límite de seguridad:
algunas banderas, especialmente
O_NOFOLLOW,
dependen de la plataforma.
Las comprobaciones reducen riesgos,
pero no garantizan protección total frente
a sustituciones concurrentes de archivos
en un directorio no confiable.
Buscar contenido con buscar_notas()
La última operación recibe un término y devuelve los nombres de las notas cuyo texto lo contiene. No utiliza IA, índices semánticos ni expresiones regulares para buscar: realiza una comparación literal de subcadenas.
Qué debe aceptar una consulta
- El valor debe ser una cadena de texto.
- Los caracteres de control ASCII, como saltos de línea y tabulaciones, se rechazan incluso al principio o al final.
-
strip()elimina espacios exteriores válidos ycasefold()facilita la comparación sin distinguir mayúsculas. - El término normalizado debe tener entre 2 y 80 caracteres.
def buscar_notas(self, consulta: str) -> list[str]:
"""Busca texto literal en notas autorizadas; máximo diez resultados."""
if not isinstance(consulta, str):
raise NotesError("La búsqueda debe ser texto")
# Validar antes de strip: un salto de línea al principio o al final
# también es una entrada inválida, aunque strip lo eliminaría.
if any(ord(ch) < 32 or ord(ch) == 127 for ch in consulta):
raise NotesError("La búsqueda no admite caracteres de control")
term = consulta.strip().casefold()
if not 2 <= len(term) <= 80:
raise NotesError("La búsqueda debe tener entre 2 y 80 caracteres")
found = []
for nombre in self._names():
try:
content = self.leer_nota(nombre)
except NotesError:
continue
if term in content.casefold():
found.append(nombre)
if len(found) >= MAX_RESULTS:
break
return found
Cómo encuentra y devuelve las coincidencias
El método obtiene primero la lista filtrada con
self._names().
Para cada archivo llama a
self.leer_nota(nombre),
de modo que reutiliza las restricciones de lectura.
Si una nota falla con NotesError,
la búsqueda la omite y continúa.
Después comprueba
term in content.casefold().
Cada coincidencia añade solamente el nombre
del archivo a la lista found;
cuando alcanza MAX_RESULTS,
finaliza.
Consulta con resultado
buscar_notas('PERMISOS')
devuelve ['seguridad.md']
con las notas del repositorio.
Consulta sin resultado
buscar_notas('rust')
devuelve [] porque las notas
de ejemplo no contienen ese término.
Qué significa buscar:
encontrar permisos significa
detectar esa secuencia en el texto.
Este ejemplo no interpreta el significado
ni responde preguntas sobre los documentos.
Resumen del núcleo: tres métodos, una política de acceso
Terminamos el estudio de notas_core.py.
El módulo no sabe nada del transporte MCP;
se limita a comprobar, enumerar, leer y buscar
notas Markdown dentro de una carpeta confiable.
listar_notas()
Devuelve una lista ordenada de nombres de archivos regulares que cumplen la política de nombres.
leer_nota(nombre)
Valida nombre, tipo, tamaño y codificación antes de devolver el contenido.
buscar_notas(consulta)
Busca una subcadena normalizada y limita a diez los resultados devueltos.
Cómo sabemos que este comportamiento se comprueba
En tests/test_notas_core.py
existen pruebas para nombres peligrosos,
tamaños máximos, codificación UTF-8,
CRLF de Windows, subdirectorios,
límites de cantidad, búsquedas inválidas
y ausencia de modificaciones en los documentos.
Las pruebas de enlaces simbólicos pueden marcarse
como skipped en Windows cuando el sistema
no permite crearlos; esto no equivale
a que hayan pasado.
Lección de ingeniería: separar el núcleo de MCP facilita razonar sobre los datos, aplicar validaciones consistentes y probar comportamientos sin depender de la interfaz del servidor.
Advertencia: este es un ejemplo local de solo lectura, no un entorno de aislamiento completo ni un servicio preparado para exponerse públicamente a entradas y directorios no confiables.
Siguiente paso: conectar el núcleo con MCP
En la Fase D analizaremos server.py:
cómo se construye MCPServer,
cómo se registran las tres herramientas
y cómo los errores del núcleo se traducen
a respuestas de herramienta.
Construir el servidor MCP: de funciones Python a herramientas
Ya sabemos cómo leer y buscar notas Markdown con NotesStore. Ahora necesitamos que un cliente pueda utilizar esas operaciones mediante MCP.
Esa es la responsabilidad de
server.py: construir el servidor,
registrar las herramientas disponibles y
conectar cada solicitud con los métodos de
notas_core.py.
No volveremos a implementar la búsqueda ni la lectura de archivos. Reutilizaremos la lógica que estudiamos anteriormente, manteniendo separadas las responsabilidades.
Las responsabilidades de nuestro servidor
Configurar
Crear el servidor MCP y establecer qué carpeta de notas consultará.
Exponer
Registrar tres funciones Python como herramientas que un cliente puede invocar.
Comunicar
Atender solicitudes, devolver resultados y comunicar errores de herramienta.
Principio de arquitectura: una herramienta MCP funciona como una interfaz para una capacidad del programa. La interfaz comunica qué se puede solicitar; NotesStore se encarga de realizar el trabajo.
Importaciones: conectar el SDK MCP con nuestro núcleo
Empezaremos leyendo las importaciones reales
de server.py.
Este proyecto utiliza el SDK de Python MCP,
instalado mediante la dependencia
mcp[cli]==2.3.0.
Biblioteca estándar
Path permite construir
la ruta a la carpeta local de notas.
__future__.annotations
permite diferir la evaluación de las
anotaciones de tipos.
SDK de MCP
MCPServer crea el servidor;
ToolAnnotations describe
propiedades de las herramientas;
ToolError representa
errores durante su ejecución.
Por último importamos
NotesStore y
NotesError desde
notas_core.py.
Esta dependencia va en una dirección:
el servidor conoce al núcleo, pero el
núcleo no necesita conocer MCP.
from __future__ import annotations
from pathlib import Path
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations
from notas_core import NotesError, NotesStore
¿Dónde se encuentran las notas?
La constante NOTAS utiliza
Path(__file__).resolve().parent
para localizar el directorio donde se
encuentra server.py.
Después añade la carpeta notas.
Así, la ubicación predeterminada depende del archivo del proyecto y no del directorio desde el que abrimos la terminal.
¿Qué significa READ_ONLY?
ToolAnnotations permite aportar
información sobre el comportamiento esperado
de una herramienta.
read_only_hint=True
Declara que las herramientas están diseñadas para consultar información sin modificarla.
open_world_hint=False
Describe la intención de trabajar con un entorno cerrado y limitado, en lugar de interactuar libremente con sistemas externos.
Importante: estas anotaciones son pistas informativas para los clientes. No activan automáticamente controles de acceso ni aíslan el sistema. Las restricciones efectivas dependen del código implementado y de los permisos del proceso.
NOTAS = Path(__file__).resolve().parent / "notas"
READ_ONLY = ToolAnnotations(read_only_hint=True, open_world_hint=False)
Crear el servidor con create_server()
En lugar de construir el servidor directamente
en muchas partes del programa, agrupamos su
configuración en una función:
create_server().
Recibe un argumento opcional,
root, que señala la carpeta
de notas. Si no especificamos uno,
utiliza la ruta definida en
NOTAS.
Dos objetos con responsabilidades diferentes
store = NotesStore(root)
Crea el objeto encargado de validar y consultar los archivos locales. Todavía no registra herramientas MCP.
server = MCPServer(...)
Crea el servidor y le asigna un nombre. Después registraremos las funciones que podrán invocar los clientes.
¿Por qué permitir cambiar root?
Este diseño facilita las pruebas automatizadas: podemos crear un servidor que consulte una carpeta temporal en lugar de utilizar los documentos reales del proyecto.
El argumento root
es parte de la configuración Python,
no un parámetro de las herramientas.
Un cliente MCP no recibe una operación
para cambiar libremente esta carpeta.
Concepto importante: separar la construcción del servidor de su ejecución permite comprobar sus herramientas sin iniciar un proceso independiente.
def create_server(root: Path = NOTAS) -> MCPServer:
"""Configura la carpeta para tests sin permitir que un agente la cambie."""
store = NotesStore(root)
server = MCPServer("TuCodigoCotidiano - Notas seguras")
Los decoradores convierten funciones en herramientas MCP
Dentro de create_server()
definimos tres funciones Python.
Antes de cada una colocamos el decorador
@server.tool(annotations=READ_ONLY).
Cuando se ejecuta la configuración, el SDK registra esas funciones como herramientas del servidor. El cliente puede descubrir sus nombres y los argumentos que acepta cada una.
Nombre
listar_notas,
buscar_notas o
leer_nota.
Argumentos
Se describen mediante la firma de las funciones y sus anotaciones de tipos.
Descripción
Los docstrings explican brevemente la finalidad de cada herramienta.
¿De dónde sale la variable store?
Las funciones registradas se definen
dentro de create_server()
y utilizan la variable store
creada anteriormente.
Python permite que estas funciones internas conserven acceso a variables del contexto donde se definieron. Este mecanismo se conoce como closure o cierre léxico.
Ventaja:
las tres herramientas comparten una
instancia configurada de
NotesStore, sin necesidad
de exponer la carpeta raíz como argumento
de cada llamada MCP.
Primera herramienta MCP: listar_notas()
Nuestra primera herramienta permite descubrir qué notas Markdown están disponibles para consultar. No recibe argumentos y devuelve una lista de cadenas.
Explicación del código
-
Registro:
@server.tool(annotations=READ_ONLY)convierte la función en una herramienta. -
Firma:
listar_notas() -> list[str]indica que no hay argumentos y que el resultado es una lista de textos. -
Delegación:
store.listar_notas()realiza la operación utilizando el núcleo que ya estudiamos. -
Error:
si NotesStore genera
NotesError, el servidor lo convierte enToolError.
Resultado esperado:
con los documentos del repositorio,
el resultado lógico contiene
python.md y
seguridad.md.
La capa MCP lo entrega al cliente
como resultado de la herramienta.
@server.tool(annotations=READ_ONLY)
def listar_notas() -> list[str]:
"""Lista los nombres de archivos Markdown autorizados."""
try:
return store.listar_notas()
except NotesError as exc:
raise ToolError(str(exc)) from exc
Segunda herramienta MCP: buscar_notas()
A diferencia del listado, esta herramienta necesita un argumento. El cliente proporciona una consulta y el servidor devuelve los nombres de las notas que contienen ese término.
Contrato de la herramienta
-
Nombre:
buscar_notas -
Argumento:
consulta, de tipo texto. -
Retorno:
list[str]. -
Ejemplo:
consulta="permisos".
Qué hace cada capa
El SDK MCP recibe los argumentos estructurados y los entrega a la función Python registrada.
Luego el servidor llama a
store.buscar_notas(consulta).
Es NotesStore quien rechaza
términos demasiado cortos, caracteres
de control y valores que no sean texto.
Reutilización:
no copiamos la validación ni la
búsqueda en el servidor.
La lógica sigue teniendo una única
implementación en notas_core.py.
Consulta inválida:
si enviamos consulta="x",
el núcleo genera NotesError,
porque exige entre dos y ochenta
caracteres después de normalizar
la consulta.
@server.tool(annotations=READ_ONLY)
def buscar_notas(consulta: str) -> list[str]:
"""Busca texto en las notas Markdown (hasta diez resultados)."""
try:
return store.buscar_notas(consulta)
except NotesError as exc:
raise ToolError(str(exc)) from exc
Tercera herramienta MCP: leer_nota()
Ya podemos descubrir archivos y buscar términos. Ahora necesitamos recuperar el contenido de un documento concreto.
La herramienta leer_nota
recibe el nombre de una nota Markdown y
devuelve su contenido como una cadena de texto.
Argumento
nombre="python.md"
El cliente indica exactamente qué nota desea consultar.
Resultado
Una cadena con el contenido Markdown de la nota.
El servidor no modifica el documento original.
¿Quién comprueba la seguridad del archivo?
La función MCP no abre archivos directamente.
Delega la operación en
store.leer_nota(nombre).
Ya estudiamos las verificaciones de
NotesStore:
nombres permitidos, archivos regulares,
límites de tamaño, apertura de lectura,
codificación UTF-8 y cierre de recursos.
Ejemplo válido:
leer_nota("python.md")
devuelve el documento que comienza
con el encabezado
«Python para proyectos reales».
Ejemplo rechazado:
leer_nota("../secreto.md")
no permite acceder a una ruta externa.
El núcleo rechaza el nombre antes de
intentar leer ese documento.
Esto demuestra una ventaja de nuestra arquitectura: podemos cambiar la interfaz que consulta los documentos sin duplicar las reglas de validación.
@server.tool(annotations=READ_ONLY)
def leer_nota(nombre: str) -> str:
"""Lee por nombre exacto una nota Markdown, por ejemplo python.md."""
try:
return store.leer_nota(nombre)
except NotesError as exc:
raise ToolError(str(exc)) from exc
Errores de herramienta y ejecución del servidor
Las tres herramientas que registramos
utilizan el mismo patrón para tratar
los errores de NotesStore.
Cada función ejecuta su operación
dentro de un bloque try.
Cuando el núcleo genera
NotesError,
el servidor lo transforma en
ToolError.
¿Por qué transformar la excepción?
NotesError
Pertenece al núcleo Python e identifica un problema al consultar las notas.
ToolError
Pertenece a la capa MCP y permite comunicar un fallo durante la ejecución de una herramienta.
Cliente
Recibe el resultado de la llamada y puede comprobar si la herramienta terminó con error.
El significado de raise ... from exc
La instrucción
raise ToolError(str(exc)) from exc
crea una nueva excepción conservando
internamente la relación con la causa original.
Esto ayuda a comprender qué operación falló sin obligar a que la lógica de archivos conozca las clases del SDK MCP.
Una distinción importante:
capturamos los errores previstos de
NotesStore.
Esto no significa que cualquier excepción
del programa quede automáticamente
convertida en un NotesError.
Cómo arranca realmente el servidor
Al terminar de registrar las herramientas,
create_server()
devuelve el objeto server.
La instrucción
mcp = create_server()
construye una instancia del servidor.
Después se utiliza la condición
if __name__ == "__main__"
para distinguir entre ejecutar el archivo
directamente e importarlo desde otro módulo.
Cuando ejecutamos server.py
como programa, mcp.run()
inicia el servidor utilizando el transporte
STDIO predeterminado.
Cuando cliente_demo.py
importa mcp, esa condición
no se cumple. El cliente puede usar
directamente el servidor en memoria,
sin llamar a mcp.run().
return server
mcp = create_server()
if __name__ == "__main__":
mcp.run()
Comprobar que el servidor expone exactamente tres herramientas
Ya construimos toda la capa MCP del proyecto. Tenemos un servidor configurado, tres funciones registradas y una forma de comunicar los errores del núcleo.
¿Qué podemos comprobar mediante el cliente?
-
Descubrir las herramientas con
client.list_tools(). -
Confirmar que existen exactamente
listar_notas,buscar_notasyleer_nota. -
Invocar
listar_notassin argumentos. -
Buscar la palabra
permisos. -
Leer
python.mdy comprobar su contenido. -
Intentar leer
../privado.mdy comprobar que la llamada termina con un error de herramienta.
Estas operaciones ya cuentan con pruebas
en tests/test_mcp.py.
El archivo utiliza un cliente MCP real
conectado a un servidor creado con
una carpeta temporal de pruebas.
Resultado de esta fase:
nuestros métodos Python ya están
expuestos como herramientas MCP.
El servidor tiene una responsabilidad
clara: comunicar solicitudes y resultados,
delegando la validación y lectura
a NotesStore.
Lo que este servidor no hace
No ofrece herramientas para crear, modificar o borrar notas. Tampoco implementa autenticación, aislamiento multiusuario ni una API HTTP preparada para Internet público.
Las anotaciones de solo lectura ayudan a describir las herramientas, pero la seguridad depende también de las operaciones implementadas y del entorno donde ejecutamos el proceso.
Siguiente paso: utilizar el servidor desde Python
En la Fase E estudiaremos
cliente_demo.py.
Aprenderemos a conectarnos con
Client, utilizar
asyncio, invocar herramientas
mediante call_tool
e interpretar los resultados.
Crear un cliente MCP con Python: del servidor a las llamadas reales
Ya construimos un servidor capaz de listar, buscar y leer notas Markdown. Ahora vamos a comprobarlo desde el otro extremo: una aplicación cliente que utiliza sus herramientas.
Para ello estudiaremos
cliente_demo.py,
un programa pequeño que utiliza
el SDK MCP, programación asíncrona
y llamadas estructuradas.
Nuestro cliente no necesita ChatGPT, un modelo de lenguaje ni una clave de API. Las operaciones se realizan en el entorno local de Python.
Qué aprenderemos
Conectarnos
Abrir una conexión con un servidor
MCP mediante Client.
Invocar
Enviar nombres de herramientas y
argumentos con call_tool.
Interpretar
Revisar errores y mostrar los resultados estructurados.
Idea principal: construir un servidor MCP es solo una parte del sistema. Un cliente compatible necesita establecer una conexión y utilizar las herramientas que el servidor ofrece.
Las importaciones de cliente_demo.py
El cliente necesita solamente tres importaciones. Cada una cumple una responsabilidad diferente.
asyncio
Biblioteca estándar de Python que permite ejecutar la función asíncrona principal.
Client
Clase del SDK MCP que permite establecer la conexión e invocar herramientas del servidor.
mcp
Instancia del servidor que
construimos anteriormente
en server.py.
¿Por qué importamos directamente el servidor?
La instrucción
from server import mcp
permite entregar al cliente la instancia
Python del servidor.
Al importar server.py,
se ejecuta mcp = create_server().
Sin embargo, no se ejecuta
mcp.run(), porque esa
instrucción está protegida por
if __name__ == "__main__".
Distinción importante: este cliente utiliza una conexión en memoria con el servidor. No inicia un proceso STDIO externo como hace el flujo de MCP Inspector.
import asyncio
from mcp import Client
from server import mcp
Programación asíncrona: comprender async y await
Las operaciones de comunicación pueden necesitar esperar respuestas. Python permite expresar estos flujos mediante programación asíncrona.
En nuestro cliente, la función principal
se declara con async def.
Esto significa que puede utilizar
expresiones como await
para esperar operaciones asíncronas.
async def
Define una función asíncrona, también llamada corrutina. Su cuerpo puede esperar operaciones sin bloquear de la misma manera que una llamada síncrona convencional.
await
Espera el resultado de una operación asíncrona. Mientras espera, el bucle de eventos puede ejecutar otras tareas disponibles.
El papel de asyncio.run()
Al ejecutar el programa, utilizamos
asyncio.run(main()).
Esta llamada crea y administra
el bucle de eventos necesario para
ejecutar nuestra corrutina principal.
No confundas los conceptos:
asyncio pertenece
a Python y no es parte exclusiva
del protocolo MCP.
El SDK aprovecha este mecanismo
para implementar su cliente asíncrono.
async def main() -> None:
async with Client(mcp, raise_exceptions=True) as client:
Establecer una conexión con Client
Para comunicarse con nuestro servidor,
el programa construye un cliente
utilizando la clase Client
del SDK MCP.
La instrucción
async with Client(mcp, raise_exceptions=True) as client
establece un contexto asíncrono.
Dentro de ese contexto podemos
realizar las llamadas a las
herramientas disponibles.
Descomponer la instrucción
-
Client: es la clase del SDK que implementa las operaciones del cliente MCP. -
mcp: es la instancia del servidor importada desdeserver.py. -
raise_exceptions=True: configura la propagación de excepciones según el comportamiento del SDK. El programa también comprueba explícitamente los resultados marcados como error. -
async with: administra la entrada y salida del contexto de conexión. -
as client: proporciona el objeto que usaremos para invocar las herramientas.
Ventaja de utilizar un contexto:
al terminar el bloque
async with,
el SDK puede liberar los recursos
asociados con la conexión.
Invocar herramientas MCP mediante call_tool()
Una vez conectado, el cliente necesita indicar qué herramienta quiere ejecutar y proporcionar los argumentos necesarios.
En lugar de escribir tres llamadas independientes, nuestro programa utiliza un bucle que recorre los nombres y argumentos de las tres herramientas.
Las tres solicitudes del ejemplo
listar_notas
Recibe un diccionario vacío
{}, porque
la herramienta no necesita argumentos.
buscar_notas
Recibe
{"consulta": "permisos"}
para buscar esa palabra
en los documentos.
leer_nota
Recibe
{"nombre": "python.md"}
para recuperar el contenido
de esa nota.
¿Qué devuelve call_tool?
El método client.call_tool
ejecuta una herramienta y devuelve
un objeto de resultado MCP.
El argumento nombre
selecciona la herramienta,
mientras que argumentos
contiene el diccionario que se enviará.
Detalle de Python:
el bucle for nombre, argumentos
desempaqueta cada pareja de valores.
Así podemos reutilizar la misma
operación para diferentes herramientas.
for nombre, argumentos in (
("listar_notas", {}),
("buscar_notas", {"consulta": "permisos"}),
("leer_nota", {"nombre": "python.md"}),
):
result = await client.call_tool(nombre, argumentos)
Comprobar errores e interpretar los resultados del cliente
Después de cada llamada, el programa revisa el objeto que recibió. No debemos asumir que toda solicitud termina correctamente.
Primero: ¿la herramienta indicó un error?
La propiedad result.is_error
permite comprobar si la respuesta
corresponde a un fallo de herramienta.
Si su valor es verdadero,
el programa genera un
RuntimeError
que incluye el nombre de la operación
y el contenido asociado al error.
Después: acceder a los datos estructurados
Para las llamadas correctas, nuestro
cliente imprime
result.structured_content.
En este proyecto, los resultados
correctos contienen una clave
result con el dato
devuelto por la herramienta:
una lista de nombres o una cadena
con el texto Markdown.
Respuesta correcta
is_error es falso
y podemos consultar
structured_content.
Error de herramienta
Si is_error es
verdadero, el cliente evita
tratar la respuesta como
si fuera un resultado correcto.
Una distinción importante:
la opción raise_exceptions=True
no sustituye el examen de
result.is_error.
Nuestro ejemplo contempla tanto
las excepciones de la operación
como los errores comunicados
mediante resultados de herramienta.
if result.is_error:
raise RuntimeError(f"Error en {nombre}: {result.content}")
print(f"{nombre}: {result.structured_content}")
"""Cliente MCP en memoria; no requiere IA ni claves de API."""
import asyncio
from mcp import Client
from server import mcp
async def main() -> None:
async with Client(mcp, raise_exceptions=True) as client:
for nombre, argumentos in (
("listar_notas", {}),
("buscar_notas", {"consulta": "permisos"}),
("leer_nota", {"nombre": "python.md"}),
):
result = await client.call_tool(nombre, argumentos)
if result.is_error:
raise RuntimeError(f"Error en {nombre}: {result.content}")
print(f"{nombre}: {result.structured_content}")
if __name__ == "__main__":
asyncio.run(main())
Ejecutar nuestro cliente MCP desde la terminal
Ahora que entendemos el código, podemos ejecutarlo. Necesitamos tener el proyecto en nuestro equipo, instalar sus dependencias y utilizar una versión de Python compatible.
Sitúate en la carpeta
mcp-python-secure-notes,
donde se encuentran
cliente_demo.py,
server.py y
requirements.txt.
¿Qué instalaremos?
El archivo requirements.txt
declara la dependencia
mcp[cli]==2.3.0.
La instalación mediante
pip install -r requirements.txt
utiliza esa versión concreta del SDK.
Por qué utilizar un entorno virtual: permite mantener las dependencias del proyecto separadas de otras instalaciones de Python.
Windows: PowerShell
En Windows crearemos un entorno virtual con Python 3.12, instalaremos las dependencias y ejecutaremos el cliente.
py -3.12 -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
.\.venv\Scripts\python.exe cliente_demo.py
python3 -m venv .venv
./.venv/bin/python -m pip install -r requirements.txt
./.venv/bin/python cliente_demo.py
Interpretar la salida de nuestro cliente MCP
Si el proyecto está correctamente instalado y las notas de ejemplo conservan su contenido original, las tres llamadas deben finalizar correctamente.
El cliente imprime una línea por
herramienta utilizando
result.structured_content.
La salida esperada contiene un
diccionario con la clave
result.
Qué significa cada resultado
Listado correcto
Aparecen python.md
y seguridad.md,
ordenados por su nombre.
Búsqueda correcta
La palabra permisos
aparece en el contenido de
seguridad.md.
Lectura correcta
El cliente recibe el texto
de python.md
con su encabezado y párrafos Markdown.
¿Por qué aparecen caracteres como \n?
El programa imprime la representación
de un diccionario Python.
Por eso los saltos de línea contenidos
en una cadena pueden aparecer
representados como \n,
en lugar de mostrarse como párrafos.
Qué acabamos de demostrar: un cliente Python puede comunicarse con nuestro servidor MCP, ejecutar herramientas y recuperar información local sin utilizar un modelo de lenguaje.
listar_notas: {'result': ['python.md', 'seguridad.md']}
buscar_notas: {'result': ['seguridad.md']}
leer_nota: {'result': '# Python para proyectos reales\n\nPython permite crear APIs, automatizaciones y agentes de IA.\nEste ejemplo usa MCP v2, funciones Python y pytest.\n'}
Resumen: nuestro primer cliente MCP está funcionando
Hemos estudiado el cliente completo, desde sus importaciones hasta el resultado de las tres herramientas.
Ahora podemos distinguir con claridad las responsabilidades de cada componente.
asyncio
Ejecuta nuestra función principal y permite esperar las llamadas asíncronas.
Client
Establece la conexión MCP con nuestro servidor Python.
call_tool
Invoca herramientas por nombre, envía argumentos y recibe resultados.
Lo que conviene recordar
- MCP permite que distintas aplicaciones utilicen herramientas mediante una interfaz de comunicación compartida.
- Nuestro cliente se conecta al objeto servidor directamente en memoria.
- Los argumentos de las herramientas se envían como diccionarios Python.
- Debemos comprobar si una llamada produjo un error antes de utilizar su resultado.
- No hace falta contratar una API de IA para construir y probar un cliente MCP.
Resultado de esta fase: hemos conectado un cliente con nuestro servidor y aprendido a invocar tres herramientas que consultan archivos Markdown.
Siguiente paso: comprobar el programa con pruebas automatizadas
En la Fase F estudiaremos
las pruebas unitarias y de integración
del proyecto. Aprenderemos a utilizar
pytest para verificar
que las herramientas funcionan
correctamente y rechazan
entradas no permitidas.
Probar nuestro servidor MCP con pytest: comprobar antes de confiar
Nuestro servidor ya puede listar, buscar y leer documentos Markdown. Pero que un programa funcione en una demostración no significa que responda correctamente ante entradas inválidas o situaciones inesperadas.
En esta fase utilizaremos pytest
para comprobar automáticamente el comportamiento
de nuestro núcleo Python y de las herramientas MCP.
Trabajaremos con las pruebas reales del repositorio,
organizadas en tests/test_notas_core.py
y tests/test_mcp.py.
¿Qué queremos comprobar?
Funcionalidad
Que las notas se puedan listar, buscar y leer correctamente.
Validaciones
Que los nombres peligrosos, tamaños excesivos y datos inválidos produzcan errores controlados.
Integración MCP
Que un cliente pueda invocar las tres herramientas y recibir las respuestas esperadas.
Principio fundamental: una prueba no demuestra que un sistema sea completamente seguro. Comprueba una propiedad concreta bajo determinadas condiciones. Por eso necesitamos casos normales, errores y situaciones límite.
Pruebas unitarias e integración: dos niveles de comprobación
Nuestro proyecto separa la lógica de archivos de la comunicación MCP. Esa arquitectura también nos permite probar ambas responsabilidades de forma independiente.
Pruebas del núcleo
En test_notas_core.py
comprobamos directamente los métodos
de NotesStore.
Estas pruebas no necesitan crear
un cliente MCP.
Pruebas de integración
En test_mcp.py
construimos un servidor,
conectamos un cliente real del SDK
e invocamos sus herramientas.
¿Por qué necesitamos ambas?
Si una prueba directa de NotesStore
falla, podemos investigar la lógica del núcleo.
Si esa prueba funciona, pero la integración falla,
debemos revisar el registro de herramientas,
los argumentos o el intercambio de resultados.
Ventaja de la separación: cada grupo de pruebas aporta información sobre una capa distinta del programa, lo que facilita localizar y corregir defectos.
Instalar pytest y preparar el entorno de pruebas
En la fase anterior instalamos las dependencias
necesarias para ejecutar el cliente.
Ahora utilizaremos
requirements-dev.txt,
que añade las herramientas de desarrollo.
Este archivo incluye el contenido
de requirements.txt
mediante la opción -r
y fija la versión de
pytest utilizada
por el proyecto.
¿Qué significa cada dependencia?
-
-r requirements.txt: instala las dependencias principales, incluido el SDK MCP. -
pytest==9.0.2: instala la herramienta con la que descubriremos y ejecutaremos las pruebas automatizadas.
Los archivos de pruebas utilizan nombres
que empiezan por test_.
pytest los descubre y ejecuta
automáticamente.
-r requirements.txt
pytest==9.0.2
.\.venv\Scripts\python.exe -m pip install -r requirements-dev.txt
.\.venv\Scripts\python.exe -m pytest -q -rs
./.venv/bin/python -m pip install -r requirements-dev.txt
./.venv/bin/python -m pytest -q -rs
Comentarios y valoraciones
No hay comentarios aún. ¡Sé el primero en opinar!