Inicio / Artículos

FastMCP Voice Stack Parte 1: Herramientas, Stdio/HTTP, STT y TTS

Parte 1 de un agente de voz: un servidor de herramientas MCP con FastMCP a través de stdio y HTTP, asistentes para clima y visión, además de conversión de voz a texto mediante micrófono y salida de texto a voz hablado conectada.

Editor: REACTAPP.TOP15 min de lectura3461 palabras

Fuentes: **Part 1: VOICE ASSISTANT, FASTMCP, LANGRAPH(J.A.V.I.S)🤖**

Desarrollo de un agente de voz con herramientas

Esta guía aborda los servidores MCP, la conversión de voz a texto, la conversión de texto a voz, y un flujo de proyecto en el que un agente escucha la voz, llama a herramientas a través de MCP y reproduce los resultados en voz alta. “LLM” se refiere al modelo de lenguaje grande que toma las decisiones.

FastMCP y el Protocolo de Contexto del Modelo

MCP es un protocolo abierto que expone herramientas, recursos y comandos a los modelos. Un servidor MCP lista las herramientas disponibles; los clientes (agentes de LangChain, Claude Desktop, Cursor y similares) se conectan y permiten que el modelo invoque esas herramientas.

FastMCP es un framework en Python para servidores MCP, análogo a Flask para aplicaciones web o FastAPI para APIs HTTP. Se decoran funciones comunes con @mcp.tool para publicarlas.

Transportes

Estandar de entrada/salida. El cliente inicia server.py como un subproceso y envía datos en formato JSON a través de stdin/stdout. No se utiliza red. Es ideal para herramientas que funcionan únicamente a nivel local y dependen del proceso del agente.

HTTP. El servidor escucha en una dirección y puerto específicos (por ejemplo 127.0.0.1:8000) y permanece activo de forma independiente. Los clientes, ya sean locales o remotos, que puedan acceder a la URL pueden utilizar estas herramientas. Para exponer un servidor en una computadora portátil, un túnel como Ngrok es una opción común.

Server y cliente de estándar de entrada/salida mínimos

Configura el entorno con uv y luego crea un servidor de estándar de entrada/salida muy sencillo:

On windows(Powershell):
PS>> irm https://astral.sh/uv/install.ps1 | iex
Linux:
linux> curl -LsSf https://astral.sh/uv/install.sh | sh
Verify Installation:
 Ps>> uv --version
Linux:
 linux> uv --version

Create a virtual environment:
Ps>> uv venv
Linux:
 linux> uv venv
Now activate our virtual environment:
 Ps>>  “.venv/Scripts/activate”
Linux:
 linux> source .venv/bin/activate

To install packages using uv we do:
Ps>> uv pip install <package_name>

- In our case we need fastMCP library:
Ps>> uv pip install fastmcp
#!/usr/bin/python3
from fastmcp import FastMCP     # our library
from datetime import datetime  # used in our second tool

# create an object from the library
mcp=FastMCP("Demo🚀🚀")

# Python decorator--> from the FastMCP library
# The "@" decorator below exposes the function as a tool
@mcp.tool
def calculate(a:int, b:int)-> int:
    """
    Add two Numbers.
    """
    # the above is a Python DocString--> AI reads it to know when to use the tool

    return a+b

# lets add another tool
@mcp.tool
def current_time()-> str:
    """
    Returns the current local date and time
    """

    # call the datetime library to get this
    return datetime.now().strftime("%Y-%m-%d %H:%M:%S")

if __name__=="__main__":
    # By default the mode of transport here is "stdio"
    mcp.run()

Puntos clave: @mcp.tool registra la función; las documentaciones indican al modelo cuándo llamarla; mcp.run() utiliza por defecto el estándar de entrada/salida.

Un cliente que no cuenta con un LLM puede listar y utilizar las herramientas directamente:

#!/usr/bin/python3
import asyncio
from fastmcp import Client

# our function will be an asynchronous function:
# MCP transport is I/O bound and the async library lets our program
# wait for tool results without freezing the whole program
# Async library also allows the AI to run multiple tools concurrently
async def main():
    # we get the server name we have for our server and pass it below
    async with Client("mcp_server.py") as client:
        # Now list available tools
        tools=await client.list_tools()
        print(f"Tools: {[t.name for t in tools]}")

        # call the add tool
        result=await client.call_tool("add", {"a":3, "b":4})
        print(f"\nTool Result: {result}")

        # call the second tool(remember, it had no arguments)
        result=await client.call_tool("current_time")
        print(f"\nCurrent time result: {result}")

# run our function
asyncio.run(main())

"""
========Exclude this: Its my tool result when I run on cmd/terminal======
example_2>python mcp_client.py
Tools: ['add', 'current_time']

Tool Result: CallToolResult(content=[TextContent(type='text', text='7', annotations=None, meta=None)], structured_content={'result': 7}, meta={'fastmcp': {'wrap_result': True}}, data=7, is_error=False)

Current time result: CallToolResult(content=[TextContent(type='text', text='2026-08-23 19:31:48', annotations=None, meta=None)], structured_content={'result': '2026-08-23 19:31:48'}, meta={'fastmcp': {'wrap_result': True}}, data='2026-08-23 19:31:48', is_error=False)

"""

Variante HTTP

Misma interfaz de herramienta, modo de ejecución diferente:

#!/usr/bin/python3
from fastmcp import FastMCP     # our library
from datetime import datetime  # used in our second tool

# create an object from the library
mcp=FastMCP("Demo🚀🚀")

# Python decorator--> from the FastMCP library
# The "@" decorator below exposes the function as a tool
@mcp.tool
def add(a:int, b:int)-> int:
    """
    Add two Numbers.
    """
    # the above is a Python DocString--> AI reads it to know when to use the tool

    return a+b

# lets add another tool
@mcp.tool
def current_time()-> str:
    """
    Returns the current local date and time
    """

    # call the datetime library to get this
    return datetime.now().strftime("%Y-%m-%d %H:%M:%S")

if __name__=="__main__":
    # We now change the mode of transport to http from the default stdio
    mcp.run(transport="http", host="127.0.0.1", port=8000)
# We now change the mode of transport to http from the default stdio
    mcp.run(transport="http", host="127.0.0.1", port=8000)

El servidor anuncia algo como http://127.0.0.1:8000/mcp. El cliente se dirige a esa URL:

#!/usr/bin/python3
import asyncio
from fastmcp import Client

# our function will be an asynchronous function:
# MCP transport is I/O bound and the async library lets our program
# wait for tool results without freezing the whole program
# Async library also allows the AI to run multiple tools concurrently
async def main():
    # we pass the localIP:port url that our server is listening on
    async with Client("http://127.0.0.1:8000/mcp") as client:
        # Now list available tools
        tools=await client.list_tools()
        # iterate through the list of tools and display only there names
        print(f"Tools: {[t.name for t in tools]}")

        # call the add tool
        result=await client.call_tool("add", {"a":3, "b":4})
        print(f"\nTool Result: {result}")

        # call the second tool(remember, it had no arguments)
        result=await client.call_tool("current_time")
        print(f"\nCurrent time result: {result}")

# run our function
asyncio.run(main())

"""
======Exclude this: Its my tool result when I run on cmd/terminal=======
example_2>python mcp_client.py
Tools: ['add', 'current_time']

Tool Result: CallToolResult(content=[TextContent(type='text', text='7', annotations=None, meta=None)], structured_content={'result': 7}, meta={'fastmcp': {'wrap_result': True}}, data=7, is_error=False)

Current time result: CallToolResult(content=[TextContent(type='text', text='2026-08-23 19:54:50', annotations=None, meta=None)], structured_content={'result': '2026-08-23 19:54:50'}, meta={'fastmcp': {'wrap_result': True}}, data='2026-08-23 19:54:50', is_error=False)

"""
# we pass the localIP:port url that our server is listening on
    async with Client("http://127.0.0.1:8000/mcp") as client:

Los resultados coinciden con stdio; solo se procesan los cambios en la topología.

Por qué FastMCP ayuda a los agentes

El modelo ve el nombre, la descripción, los parámetros y la forma del resultado, no el código interno. La infraestructura de comunicación permanece dentro del framework. El mismo servidor puede estar en un host remoto y servir a muchos clientes, a diferencia de las herramientas codificadas exclusivamente dentro de un proceso LangChain.

Servidor del proyecto MCP para el asistente de voz

Herramientas previstas para el asistente: calculadora, clima, búsqueda web en Tavily, extracción de datos con Firecrawl a formato markdown/JSON, procesamiento de imágenes y funciones relacionadas con fechas/horas. El trabajo local utiliza stdio.

Cree un proyecto con .env para las claves y .gitignore que excluya a .env. Instálelo con uv:

cmd>> uv pip install requests fastmcp simpleeval python-dotenv tavily-python firecrawl-py langchain_groq langchain_core langchain

Ponga los ayudantes más extensos dentro de un paquete tools:

cmd>> mkdir tools

Ayudante de clima

Obtenga una clave de OpenWeather, guárdela en .env e implemente la función de búsqueda:

#!/usr/bin/python3
import requests
import os
from dotenv import load_dotenv

# Load our API key from environment variables
load_dotenv()

# access it using the os library
API_KEY=os.getenv("WEB_API")

def get_weather(city):
    # fetching weather from open weather web app
    url=f"https://api.openweathermap.org/data/2.5/weather?q={city}&appid={API_KEY}&units=metric"

    # Send our HTTP request
    response=requests.get(url)

    # filter and iterate through the request
    if response.status_code==200:
        # If we connected to the web seaver. Convert the response received to json
        data=response.json()

        temperature=data["main"]["temp"]
        humidity=data["main"]["humidity"]
        description=data["weather"][0]["description"]
        wind_speed=data["wind"]["speed"]
        weather_report = f"""
    Current Weather Report

    Location: {city}
    Temperature: {temperature}°C
    Humidity: {humidity}%
    Conditions: {description}
    Wind Speed: {wind_speed} m/s
    """
        # return the above report
        return weather_report

    else:
        return f"Error retrieving weather data: {response.status_code}"

Ayudante de visión

Codifique las imágenes en formato base64 y envíelas a través de LangChain a un modelo Groq con capacidad de procesamiento visual (por ejemplo, un SKU Qwen vision/reasoning). Guarde la clave de Groq en .env.

#!/usr/bin/python3
from langchain_groq import ChatGroq
from dotenv import load_dotenv
import os
import base64
from langchain_core.messages import HumanMessage

# load environment variables--> API keys from the ".env" file
load_dotenv()

# the llm setup---> take note of the model_name that has vision
api_key=os.getenv("GROQ_API_KEY")

llm=ChatGroq(
    temperature=0.3,
    groq_api_key=os.getenv("GROQ_API_KEY"),
    model_name="qwen/qwen3.6-27b"
)

# first step is to convert the image to base64
# We will send it to the API in this format
def encode_image(image):
    # open the image in binary and encode it
    with open(image, "rb") as f:
        return base64.b64encode(f.read()).decode()

# Call the function to send encoded image data to LLM
def describe_image(image):
    # get encoded image data
    encoded_image=encode_image(image)

    # Prepare prompt
    message= HumanMessage(
        content=[
            {
                "type":"text",
                "text":"Describe this Image. Be brief"
            },
            {
                "type":"image_url",
                # fetching image locally. If not local use: 'url':'https:<url_for_iamge>'
                "image_url":{
                    "url":f"data:image/png;base64,{encoded_image}"
                }
            }
        ]
    )

    # call our LLM passing the above prompt with our encoded image data
    result=llm.invoke([message])

    # get the content part. Thats what we care about to be returned
    result=result.content

    # Remove reasoning section--> we want to reduce the amount of text returned--> reducing token consumption
    if "</think>" in result:
        result = result.split("</think>", 1)[1].strip()

    return result


# --------Delete this part below after testing and save only the function above-----------
print("[+] Starting description......")
# Make sure image is inthe same directory as vision.py . If not use the filepath
response=describe_image("my_quiz.png")

print(f"\nImage Data: {response}")

"""
=================This is the result I got=======================
cmd>> python vision.py
[+] Starting description......

Image Data: The image displays a text-based "Python Quick Quiz" containing three questions. The text is shown twice: once at the top and again in a zoomed-in view below. The questions are:

1.  Define Python in one line
2.  Difference between variable and constant in one line
3.  Write code to get input from the user and add the two numbers

"""
cmd>> cd ..

# make a file mcp_server.py if using vs code
# Now the shell should look like below
cmd>> ls
tools mcp_server.py .env

Montaje de mcp_server.py

Agregue las claves de Firecrawl y Tavily, importe los ayudantes e inscriba las herramientas restantes:

#!/usr/bin/python3
from datetime import datetime
from fastmcp import FastMCP
from simpleeval import simple_eval
import os
from dotenv import load_dotenv
from tavily import TavilyClient
from firecrawl import FirecrawlApp
from typing import Literal

# user defined function here. Located in the tools directory
from tools.vision import describe_image
from tools.weather import get_weather

# ----------Loads APIs Accessible to our Program----------
#loads environment variables. Our APIs in .env file to be accessible here
load_dotenv()

# we get the Tavily API here--> use os library and save to a variable
tavily_client=TavilyClient(api_key=os.getenv("TAVILLY_API")) # Tavily

# firecrawl setup. Get API key using OS library and save to a variable
app=FirecrawlApp(api_key=os.getenv("FIRECRAWL"))
#---------------------------------------------------------

# create an MCP object from the class
mcp=FastMCP("local-tools")

#-----1. Time tool
# the decorator turns our function into a tool
@mcp.tool
def current_time()-> str:
    """
    Return the current local date and time
    Use this when the user asks what time or date it is
    """
    # Docstring provides tool description to the LLM
    return datetime.now().strftime("%Y-%m-%d %H:%M:%S")

#----2: Calculator to solve expressions
#Next tool will be a calculator. Can solve complex maths equations
@mcp.tool
def calculate(expression: str)-> str:
    """
    Safely evaluate a mathematical expression
    Returns a string suitable for an LLM
    """
    try:
        # pass the expression from an LLM here
        result=simple_eval(expression)
        return f"""
Calculation

Expression: {expression}
Result: {result}
"""
    except Exception as e:
        return f"Calculation Error: {e}"

#----3. Vision into images
# ability to describe images. We wrote the code in the tools directory "vision.py"
@mcp.tool
def image_info(image_path: str)-> str:
    """
    Use this when user wants to know what the image entails
    User must have provided the path to where the image is
    The image can be on the web via URL or on local machine
    Returns the description of the image content
    """
    # call the function that will carry this task
    try:
        # call our "vision.py" function we have imported from tools
        result=describe_image(image_path)
        # return the result gotten from the model
        return result

    except Exception as e:
        return f"Image Description Error: {e}"

# ----4. Tavily Search Tool
@mcp.tool
def internet_search(
    query: str,
    max_results: int=5,
    topic: Literal["general", "news", "finance"] = "general", # Literal forces LLM to choose 1 from the 3 in the List
    include_raw_content:bool=False,
):
    """Run a web search"""
    try:
        return tavily_client.search(
            query,
            max_results=max_results,
            include_raw_content=include_raw_content,
            topic=topic,
        )
    except Exception as e:
        return f"Web Search Error: {e}"

# ----5. Scrape Webpage and return result in markdown
@mcp.tool
def scrape_webpage(url: str)-> str:
    """
    Read a webpage and return clean markdown suitable for an LLM
    """
    # Send our request and store the markdown data
    response=app.scrape_url(
        url=url,
        formats=["markdown"]
    )

    markdown=getattr(response, "markdown", "")

    # if the response is empty
    if not markdown:
        return "Unable to extract content from the webpage."

    return f"""
Source URL:
{url}

Extracted Content:
{markdown}
"""

# -----6. Weather forecast
@mcp.tool
def weather_forecast(city: str)-> str:
    """
    Use this tool when you have the city/Town name you want to look
    up for the current weather conditions
    """
    # call our function
    try:
        result=get_weather(city)
        return result
    except Exception as e:
        return f"Weather tool Error: {e}"

if __name__=="__main__":
    # run the MCP server over stdio
    mcp.run()

Ejercicio: ejecute el servidor desde un cliente manual sin LLM, y luego pruebe el modo HTTP desde Cursor o Claude Code para practicar más.

Entrada y salida de voz

La conversión de voz a texto transforma el audio del micrófono en cadenas de texto. Google STT en la nube es una opción; Whisper y Vosk funcionan sin conexión: se prefiere Whisper o Vosk, que son más rápidos, en máquinas con solo CPU.

Opciones de conversión de texto a voz: pyttsx3 (local, con sonido robótico, gratuito), Piper (neural local, disponible en muchos idiomas), Google TTS (en la nube, generalmente basado en archivos) y APIs de pago como ElevenLabs.

Una pequeña demostración captura el audio del micrófono con el reconocimiento de Google, ejecuta una acción simple y habla mediante pyttsx3:

cmd>> uv pip install speech_recognition pyttsx3
#!/usr/bin/python3

# Function processes the user's spoken command as transcribed by Google text to speech
# returns appropriate response
def process_command(command):
    # Checked if the user asked about the weather
    command=command.lower()   # convert the user input to lower case

    if "weather" in command:
        return "Sir, no storm today. Wear a Tshirt!"

    # check if the user requested a joke
    elif "joke" in command:
        return "Why did the Computer go to therapy? Because it had too many bytes"

    # check if the user mentions wikipedia
    elif "wikipedia" in command:
        return "Sir we have not implemented that, sorry!"

    else:
        return "Sorry Sir, I didn't understand that"

# We are not using an LLM yet
#!/usr/bin/python3
import speech_recognition as sr
import pyttsx3
# our user defined library actions.py
from actions import process_action

# -----Step 1: Listening your Voice(Speech-to-text)-----
# > Function records audio from the microphone
# > Converts it into text using Google's speech recognition API
def listen():
    # create a speech recognizer object
    recognizer=sr.Recognizer()

    # open the default microphone--> get our voice input
    with sr.Microphone() as source:
        # Print on terminal recording has started
        print("\n\n🎤Listening.....")

        # listen until the user stops speaking
        # recorded audio is stored in a variable 'audio'
        audio=recognizer.listen(source)

    # -Since interacting with an API better to use exception handling. Prevents program from crashing
    try:
        # send the recorded audio to Google's Speech Recognition services
        # transcribe our input into text
        text=recognizer.recognize_google(audio)

        # Display what has been transcribed
        print(f"You Said: {text}")

        # return text in lower case. Easier to compare it later
        return text.lower() #--> now we want to pass it to actions.py

    except sr.UnknownValueError:
        # if empty whatever was transcribed
        return ""

    except sr.RequestError:
        # incase no internet or googles speech API is unreachable
        return "\nError: Check Internet Connection"


# ------Step 2: Talking Back using pyttsx(Text-to-Speech)
def speak(text):
    # display what is to be spoken
    print(f"JARVIS🤖: {text}")

    # To avoid error we create a freshh engine instance every time
    engine=pyttsx3.init()
    # speed of the audio
    engine.setProperty('rate', 170)
    # the volume
    engine.setProperty('volume', 1)

    # execute our text to be spoken now
    engine.say(text)
    engine.runAndWait()
    engine.stop()    # clean up after speaking since we are creating a new instance each time


#------Step 3: Execute Everything---------
def main():
    speak("Hello Sir, I am JARVIS. How can I help you today? ")

    # Create an infinity loop for user interaction
    while True:
        # get the input voice and convert to text using the listen function(Has our microphone, Google text-to-speech model)
        command=listen()

        # Check if command is empty we continue--> restarts execution of the infinity loop and skips the other code
        if command=="":
            continue

        # if the command has exit or quit terminate the program
        elif "exit" in command or "quit" in command:
            speak("Goodbye Sir!")
            break

        # Now pass our command transcribed to our action.py. Process_action command will execute and return results
        response=process_action(command)

        #Output the results from our user defined function
        speak(response)

# call main function
if __name__=="__main__":
    main()

"""
=====================Do Not Add this Part: My results on CMD=======
for_blog>python main.py
JARVIS🤖: Hello Sir, I am JARVIS. How can I help you today?

🎤Listening.....
You Said: what is the weather today
JARVIS🤖: Sir, no storm today. Wear a Tshirt!


🎤Listening.....
You Said: do you do you have Wikipedia
JARVIS🤖: Sir we have not implemented that, sorry!

🎤Listening.....
You Said: tell me a joke
JARVIS🤖: Why did the Computer go to therapy? Because it had too many bytes


🎤Listening.....
You Said: exit
JARVIS🤖: Goodbye Sir!

"""
cmd>> python main.py

Fin de la primera parte

La primera parte se detiene en las herramientas MCP más un bucle básico de entrada/salida de voz. La segunda parte introduce LangChain y LangGraph para que el agente pueda orquestar esas herramientas junto con funciones de memoria y enrutamiento.

Reforzando la capa MCP y de voz antes de la segunda parte

Las demostraciones locales ocultan los modos de fallo que aparecen en cuanto se conecta un segundo cliente. Con el transporte HTTP, establezca la autenticación lo antes posible, incluso con un token de portador compartido, para que un túnel público no se convierta en una shell de herramientas accesible. Registre el nombre de la herramienta, la latencia y la clase del error; las sesiones de voz dificultan darse cuenta de que una herramienta meteorológica está esperando treinta segundos mientras el usuario espera escuchar voz.

La disciplina con respecto a los secretos es aún más importante cuando las claves de visión y búsqueda se integran en la calculadora. Mantenga los archivos .env fuera de las capturas de pantalla y grabaciones. Rotue las claves utilizadas en los tutoriales. Prefiera el principio de menor privilegio: una clave de Firecrawl que solo permita extraer datos de dominios autorizados es mejor que un token con modo divino en un repositorio estudiantil.

En cuanto a la voz, hay que decidir si se utilizará modo sin conexión o en la nube según cada etapa. Muchos desarrolladores usan STT en la nube para mayor precisión y Piper local para TTS, a fin de evitar enviar y recibir respuestas habladas. Es necesario documentar las implicaciones en materia de privacidad: el audio que sale de la máquina representa un modelo de amenazas diferente al de las herramientas de texto que operan a través de stdin. La segunda parte consistirá en encapsular estas herramientas dentro de un agente; la tarea de la primera parte es garantizar que cada herramienta sea increíblemente fiable de forma independiente, para que los errores de orquestación no se confundan con errores de MCP.

Disciplina en el catálogo de herramientas

A medida que se van acumulando las herramientas de calculadora, clima, búsqueda, rastreo, visión y reloj, publique un catálogo de una página que incluya el nombre, un resumen en la documentación, las variables de entorno requeridas, la latencia esperada y si es seguro exponer la herramienta a través de HTTP. Tanto los agentes como las personas se benefician. Durante las pruebas manuales del cliente MCP, verifique no solo los casos exitosos sino también argumentos mal formados y claves API vacías para que los fallos sean evidentes durante el desarrollo. Para las demostraciones de voz, escriba una lista fija de frases a pronunciar (“¿cuánto es dos más dos?”, “clima en Nairobi”, “resuma esta página”) y grabe las secuencias de herramientas esperadas. La regresión solo es posible cuando la ruta de voz se trata como una prueba de integración API que casualmente utiliza un micrófono. Esa preparación es lo que convierte el cableado de LangGraph en la segunda parte en un problema de composición en lugar de una maratón de depuración.

Elección del transporte local para el asistente de voz

Stdio mantiene el servidor MCP privado al proceso del agente y se ajusta a una demostración en portátil donde nada debería ser accesible desde la LAN. Cambie a HTTP solo cuando un segundo cliente (Cursor, Claude Code, la máquina de un compañero) deba compartir herramientas, e incluso entonces conéctese a localhost hasta que exista un proxy inverso con autenticación. Mezclar transportes durante el tutorial sin reiterar ese modelo de amenazas confunde a los lectores, llevándolos a exponer ampliamente las herramientas de visión y búsqueda. Explique claramente la elección cada vez que cambie el punto de entrada del servidor, y mantenga la ruta stdio como predeterminada para el proyecto del asistente de voz hasta que el agente de la segunda parte esté estable.

Antes de cerrar la primera parte, ejecute el servidor MCP con ambos transportes una vez más y pegue la lista de herramientas en el README del repositorio. El usted futuro que conecte LangGraph agradecerá al usted presente por no tener que redescubrir qué documentación necesita realmente el modelo para elegir Firecrawl en lugar de Tavily.

También verifique los permisos del micrófono y la salida del altavoz en cada sistema operativo que demuestre; muchos “errores de TTS” se deben a dispositivos silenciados. Mantenga una solución alternativa que muestre el texto del asistente cuando falla el audio para que el taller pueda continuar.

Logística del taller que salva la situación

Imprima una hoja de referencia de una página para los asistentes: cómo iniciar el servidor stdio, cómo ejecutar el cliente manual, qué claves .env se requieren para información meteorológica frente a visual, y cómo debe verse una lista de herramientas adecuada. Cuando alguien se quede atascado, pregunte si el problema ocurre antes de las herramientas (micrófono o STT), dentro de las herramientas (claves MCP o API) o después de ellas (TTS). Esa triage por sí sola reduce a la mitad el tiempo de depuración y permite avanzar con la segunda parte del taller.

Lecturas relacionadas