Creando MCP tools dinámicamente con compile() y exec()

Recientemente he explorado un aspecto de Python que rara vez se utiliza en entornos convencionales: la generación dinámica de código utilizando compile() y exec(). Aunque estas funciones suelen estar asociadas a casos de uso avanzados —y a menudo evitadas por sus riesgos de seguridad—, en este proyecto fueron clave para lograr una solución práctica y flexible.

En este artículo comparto un caso práctico dentro del proyecto mcp-openapi-proxy, donde fue necesario generar MCP Tools de manera dinámica a partir de una especificación OpenAPI. Explico el problema, las limitaciones encontradas y cómo llegué a una solución utilizando estas potentes funcionalidades del lenguaje Python.

El problema

MCP (Model Context Protocol) es un protocolo diseñado para que aplicaciones que utilizan LLMs, como agentes AI, puedan consumir servicios externos de manera estructurada y segura. En términos prácticos, un servidor MCP define un conjunto de tools que describen las operaciones que un modelo puede invocar para interactuar con un servicio exterior.

El propósito principal del proyecto era crear un proxy que, a partir de una especificación OpenAPI, generara automáticamente todas las MCP tools necesarias para exponer los endpoints definidos. Este proceso debía cumplir con los siguientes requisitos:

  • Leer una especificación OpenAPI.
  • Identificar los endpoints, sus métodos y parámetros.
  • Generar funciones de Python correspondientes a cada operación.
  • Exponer estas funciones como MCP Tools.

Esta solución debía ser altamente adaptable y no depender de implementaciones estáticas. El objetivo era evitar la escritura manual de código cada vez que se agregara o modificara un servicio en la especificación de OpenAPI.

Usando compile() y exec()

Una vez obtenidas las operaciones definidas a través del parseo de la especificación OpenAPI, es necesario proceder con la generación dinámica de las MCP Tools.

Con compile() y exec(), Python permite compilar y ejecutar código definido dinámicamente, ya sea desde cadenas de texto o archivos externos. Aunque este enfoque debe usarse con precaución, en este caso ofrecía exactamente el nivel de flexibilidad necesario para cumplir con el propósito deseado.

¿Cómo funciona compile()?

La función compile() requiere de tres argumentos obligatorios:

  1. El código fuente, que puede ser una cadena de texto (str), una cadena de bytes (bytes) o un objeto AST.
  2. El nombre del archivo desde el cual se supone que proviene el código fuente. Si no existe un archivo real (como en este caso), se suele pasar un nombre ficticio como «<string>» por convención.
  3. El modo de compilación, que determina cómo se debe interpretar el código fuente. Existen tres valores válidos:
    • «exec»: cuando el código contiene una o más instrucciones (como def, import, print, etc.).
    • «eval»: cuando el código consiste en una sola expresión (por ejemplo, 3 + 5 o x * y + z).
    • «single»: cuando el código contiene una sola instrucción que debe ejecutarse de forma interactiva, útil en entornos tipo REPL.

¿Cómo funciona exec()?

La función exec() permite la ejecución de programas Python de forma dinámica. Esta función puede recibir como argumento una cadena de texto con código o, de forma más eficiente, un objeto de código previamente compilado mediante compile().

Además, exec() puede recibir dos parámetros opcionales: globals y locals. Estos permiten definir el alcance (scope) en el que se ejecuta el código, lo cual es útil para controlar el acceso a variables y funciones dentro de contextos controlados.

Ejemplo práctico

El siguiente ejemplo muestra cómo generar una MCP tool a partir de un diccionario simulando una operación extraída de una especificación OpenAPI


operation = {
    "name": "get_user",
    "method": "GET",
    "path": "/users/{user_id}"
}

code = f'''
@mcp.tool
def {operation["name"]}(user_id):
    import requests
    response = requests.get(f"http://api.example.com{operation["path"]}".format(user_id=user_id))
    return response.json()
'''

compiled = compile(code, "<string>", "exec")
exec(compiled)

Consideraciones importantes

Usar exec() y compile() no están exentas de desafíos:

  • Seguridad: Ejecutar código generado dinámicamente implica riesgos, especialmente si la entrada no es de confianza. En este proyecto se asume que las especificaciones provienen de fuentes conocidas.
  • Debugging: El código generado no existe en archivos fuente, por lo que los errores pueden ser más difíciles de rastrear. Se recomienda imprimir o registrar el código generado durante el desarrollo.
  • Mantenibilidad: Aunque esta técnica reduce el código manual, puede dificultar la comprensión para otros desarrolladores no familiarizados con metaprogramación en Python.

Conclusión

El uso de compile() y exec() en Python puede parecer extremo, pero en escenarios como la generación dinámica de MCP Tools basadas en OpenAPI, se convierte en una solución elegante y poderosa. Como siempre, con gran poder viene una gran responsabilidad: es fundamental validar las entradas, aislar contextos de ejecución y mantener el código generado lo más transparente posible.

El código fuente completo del proyecto está disponible en GitHub.

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *