Skip to main content
Glama
openapi_tools.py8.74 kB
""" Registro dinámico de herramientas MCP a partir de un OpenAPI. En este módulo se toma una especificación OpenAPI y se crean herramientas FastMCP por cada operación, para maximizar la cobertura. Simplificaciones: - Parámetros path: se esperan como kwargs con el mismo nombre que en el spec. - Parámetros query: opcionales salvo que el spec marque required. - Cuerpo (requestBody): se pasa en el kwarg `body` (dict) y se envía como JSON. - Respuestas: se devuelve el JSON bruto de la API (raise_for_status en errores). """ from __future__ import annotations import json from pathlib import Path from typing import Any, Callable, Dict, Iterable import httpx from mcp.server.fastmcp import FastMCP from .config import get_config def _collect_parameters( path_params: list[dict[str, Any]], op_params: list[dict[str, Any]] ) -> list[dict[str, Any]]: """Recolecta y combina parámetros de path y operación.""" seen = set() merged: list[dict[str, Any]] = [] for src in (path_params, op_params): for param in src: name = param.get("name") location = param.get("in") key = (name, location) if key in seen: continue seen.add(key) merged.append(param) return merged def _sanitize_name(name: str, max_length: int = 64) -> str: """ Sanitiza un nombre para uso como tool name MCP. - Convierte a minúsculas - Reemplaza caracteres no alfanuméricos por underscore - Compacta múltiples underscores consecutivos - Trunca a max_length caracteres (default 64, límite MCP) """ out = [] for ch in name: if ch.isalnum(): out.append(ch.lower()) else: out.append("_") joined = "".join(out) clean = "_".join(filter(None, joined.split("_"))) if not clean: clean = "op" if len(clean) > max_length: clean = clean[:max_length].rstrip("_") return clean def _make_tool( *, client_factory: Callable[[], httpx.AsyncClient], method: str, path_template: str, required_path_params: list[str], required_query_params: list[str], optional_query_params: list[str], auth_params: Callable[[], dict[str, str]], auth_headers: Callable[[], dict[str, str]], has_request_body: bool, tool_name: str, ) -> Callable[..., Any]: """ Factory function que crea una tool con las variables capturadas por valor. Esto evita el bug de closure donde todas las tools terminan usando los valores de la última iteración del loop. """ async def _tool(**kwargs: Any) -> Any: return await _execute_request( client_factory=client_factory, method=method, path_template=path_template, required_path_params=required_path_params, required_query_params=required_query_params, optional_query_params=optional_query_params, auth_params=auth_params, auth_headers=auth_headers, has_request_body=has_request_body, args=kwargs, ) _tool.__name__ = tool_name return _tool def register_openapi_tools( mcp: FastMCP, *, spec_path: Path, auth_params: Callable[[], dict[str, str]], auth_headers: Callable[[], dict[str, str]], client_factory: Callable[[], httpx.AsyncClient], tool_prefix: str | None = None, ) -> int: """ Registra herramientas FastMCP para cada operación del OpenAPI. Los nombres se basan en operationId si existe; si no, en método + path. Args: mcp: Instancia de FastMCP donde registrar las herramientas spec_path: Ruta al archivo OpenAPI JSON auth_params: Función que retorna parámetros de autenticación auth_headers: Función que retorna headers de autenticación client_factory: Función que crea un cliente HTTP tool_prefix: Prefijo para los nombres de las herramientas. Si no se especifica, se usa el de config. Returns: Número de herramientas registradas. """ config = get_config() prefix = tool_prefix or config.api.tool_prefix spec = json.loads(spec_path.read_text()) paths: Dict[str, Any] = spec.get("paths", {}) seen_tool_names: set[str] = set() # Calcular max_length para el nombre sanitizado # Dejamos espacio para el prefijo + underscore prefix_len = len(prefix) + 1 # +1 por el underscore max_name_len = 64 - prefix_len for path, path_item in paths.items(): path_level_params = path_item.get("parameters", []) for method, op in path_item.items(): if method.lower() not in {"get", "post", "put", "delete", "patch"}: continue if not isinstance(op, dict): continue op_id = op.get("operationId") name_seed = op_id or f"{method}_{path}" tool_name = f"{prefix}_{_sanitize_name(name_seed, max_length=max_name_len)}" if tool_name in seen_tool_names: continue seen_tool_names.add(tool_name) op_params = op.get("parameters", []) all_params = _collect_parameters(path_level_params, op_params) required_path = [p["name"] for p in all_params if p.get("in") == "path"] required_query = [ p["name"] for p in all_params if p.get("in") == "query" and p.get("required") is True ] optional_query = [ p["name"] for p in all_params if p.get("in") == "query" and p.get("required") is not True ] summary = op.get("summary") or op.get("description") or "" doc_lines = [ f"{method.upper()} {path}", summary, f"Path params requeridos: {required_path}" if required_path else "Sin path params", f"Query requeridos: {required_query}" if required_query else "Sin query requeridos", f"Query opcionales: {optional_query}" if optional_query else "Sin query opcionales", "Body opcional: `body` (dict) si el endpoint lo admite.", ] doc = "\n".join(doc_lines) tool_func = _make_tool( client_factory=client_factory, method=method, path_template=path, required_path_params=required_path, required_query_params=required_query, optional_query_params=optional_query, auth_params=auth_params, auth_headers=auth_headers, has_request_body=op.get("requestBody") is not None, tool_name=tool_name, ) mcp.tool(name=tool_name, description=doc)(tool_func) return len(seen_tool_names) async def _execute_request( *, client_factory: Callable[[], httpx.AsyncClient], method: str, path_template: str, required_path_params: Iterable[str], required_query_params: Iterable[str], optional_query_params: Iterable[str], auth_params: Callable[[], dict[str, str]], auth_headers: Callable[[], dict[str, str]], has_request_body: bool, args: dict[str, Any], ) -> Any: """Ejecuta una request HTTP a la API.""" # Validar path params requeridos missing_path = [p for p in required_path_params if p not in args] if missing_path: raise ValueError(f"Faltan path params requeridos: {missing_path}") # Validar query params requeridos missing_query = [p for p in required_query_params if p not in args] if missing_query: raise ValueError(f"Faltan query params requeridos: {missing_query}") # Construir path path = path_template for p in required_path_params: path = path.replace("{" + p + "}", str(args[p])) # Construir query params query: dict[str, Any] = {k: args[k] for k in required_query_params if k in args} for k in optional_query_params: if k in args and args[k] is not None: query[k] = args[k] query.update(auth_params()) # Headers de autenticación headers = auth_headers() # Body json_body: Any = None if has_request_body and "body" in args: json_body = args["body"] async with client_factory() as client: resp = await client.request( method.upper(), path, params=query, json=json_body, headers=headers if headers else None, ) resp.raise_for_status() try: return resp.json() except Exception: return resp.text

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/jesusperezdeveloper/mcp_openapi_template'

If you have feedback or need assistance with the MCP directory API, please join our Discord server