Skip to content

feat(api): ejecución de tools y contrato de retorno estructurado - #101

Merged
g-garciac2022 merged 7 commits into
devfrom
feat/100-execute-y-contrato-retorno
Aug 11, 2026
Merged

feat(api): ejecución de tools y contrato de retorno estructurado#101
g-garciac2022 merged 7 commits into
devfrom
feat/100-execute-y-contrato-retorno

Conversation

@g-garciac2022

Copy link
Copy Markdown
Collaborator

#100 · POST /tools/{name}/execute y el contrato de retorno

Closes #100

El objetivo era el endpoint: /tools ya publicaba el inputSchema de cada
herramienta, así que faltaba lo que ejecutara el formulario construido con él.
Al medirlo antes de escribir código apareció que no podía existir tal cual, y
de ahí salió la mitad de esta PR.

El bloqueo: éxito y fallo eran indistinguibles

Escenario isError content[0].text
Titular válido False {"score": 3, "is_clickbait": true, …}
Titular vacío (la tool falla) False El titular está vacío o no es válido
Parámetro inexistente True Error executing tool …: validation error

isError sólo se activaba cuando el fallo ocurría en la capa MCP. Si la
herramienta devolvía un mensaje de error —lo que hacían las once— para el
protocolo era un éxito. Y «parsear como JSON» no valía de heurística:
get_forecast devuelve prosa siendo una ejecución correcta.

Se descartó -> ToolResult, la opción aparentemente obvia por existir ya. Su
esquema describe el sobre, no la cartadata queda como
anyOf: [{}, null]— y duplica el eje que MCP ya tiene: como ToolResult.fail se
devuelve y no se lanza, daría respuestas con isError: false y success: false
a la vez. ToolResult sigue intacto donde estaba, en los client.py y
base_api.py; no aparecía ni aparece en ningún tool.py.

Un fallo que llevaba desde la Épica 1 escondido

Al hacer que las tools lanzaran, el mensaje seguía perdiéndose. La causa
estaba en log_tool_invocation:

except Exception:
    log.error(..., exception=traceback.format_exc())
    return "Internal error while executing tool"

El decorador de observabilidad capturaba toda excepción y devolvía una
cadena.
Consecuencia anterior a este issue: un KeyError o un fallo de red no
capturado dentro de una tool llegaba al cliente como texto normal con isError a
False — indistinguible de un análisis correcto.

Y explica por qué el contrato parecía coherente: no era que unas tools
devolvieran errores por decisión y otras no, es que el decorador aplanaba todo
a texto
, lo devuelto a propósito y lo lanzado por accidente. El arreglo es un
raise en vez de un return, con el principio escrito en el código: el
decorador es para observar, no para decidir qué se responde
.

Salida estructurada: qué cuesta

Retorno declarado outputSchema structuredContent
-> str con json.dumps (lo anterior) {"result": string} el JSON como texto
-> dict a secas None None
TypedDict propio describe los campos reales el objeto, directo

Anotar -> dict no sirve: MCP necesita un tipo declarado. Se usan
TypedDict y no modelos Pydantic porque las capas de cliente ya devuelven
diccionarios — así no hay conversión, sólo se declara la forma que ya tienen. El
esquema publicado incluye además el docstring del tipo como description.

Nueve herramientas declaran el suyo. get_alerts y get_forecast se quedan en
-> str
: producen prosa para leer, y forzarles estructura sería inventar
campos que la salida no tiene.

El endpoint

Valida los argumentos contra el inputSchema antes de invocar (R4.5), con
iter_errors en vez de validate para acumular todos los problemas — quien
rellena un formulario prefiere corregirlo de una vez. Que la validación sea
previa es lo que permite distinguir un campo mal escrito de un análisis que
salió mal.

Situación Respuesta
La herramienta no existe 404
Los argumentos no encajan 422, con el campo y el motivo
La herramienta se ejecuta y falla 200 con status: error

La última no es un error HTTP: la petición era correcta y el servidor la atendió.
Mismo criterio que /analyze.

Y un timeout propio (mcp_execute_timeout, 60 s): mcp_timeout son 5 s, de
sobra para un list_tools de 0,036 s, pero detect_clickbait_incoherence tarda
~20 s en frío cargando embeddings. Con el margen del descubrimiento moriría
siempre.

Un fallo que habría llegado a producción

Las excepciones lanzadas dentro de una sesión MCP salen envueltas en
ExceptionGroup
: la sesión abre un task group de anyio. El
except InvalidArguments de la ruta no la habría reconocido y un argumento mal
escrito habría dado 500 en vez de 422. La lógica ahora devuelve lo
ocurrido y decide fuera de la sesión.

Es la segunda vez que anyio aparece en el proyecto — la primera fueron los
cancel scopes en los tests del catálogo.

Un hueco de cobertura

Rehacer el contrato de las once herramientas no rompió un solo test. No por
estar bien cubierto: porque nadie probaba las tools MCP. Todos los tests
atacan la capa cliente, que devuelve ToolResult y no ha cambiado.
test_tool_contract.py cubre ahora esa frontera.

Un dato para cuando llegue el agente (R13)

Se intentó revalidar la selección de herramientas con los scripts del spike #82,
porque los docstrings son la interfaz que lee el LLM. Dos cosas aparecieron, y
ninguna bloquea esta PR:

El spike #82 no medía lo que decía medir. Afirma usar «las descripciones
REALES (docstrings de tool.py)» pero las lleva copiadas a mano, resumidas a
unos 150 caracteres. Nunca probó los textos que recibe el agente.

Y las descripciones reales no caben en el contexto que asumía aquel spike:

Herramientas Tamaño
Spike #82 (resúmenes) 8 3.203 chars · ~800 tokens
Descripciones reales 11 9.448 chars · ~2.362 tokens
num_ctx del spike 2.048 tokens

Las definiciones por sí solas desbordan la ventana, antes de añadir la
consulta. Con esa configuración el modelo llegó a inventarse una herramienta
(detect_clicks_incoherence) y a pedir alertas meteorológicas para analizar un
titular — síntomas de una lista truncada, no de unos docstrings peores.

La revalidación del agente se aplaza al hito de R13, cuando haya infra
adecuada: el agente no forma parte de H2 y el hardware local es una restricción
del entorno de desarrollo, no del diseño.

Notas

Seis commits, ordenados para poder leerse por partes: el arreglo del decorador va
solo y primero, porque es un bug preexistente e independiente y no debería
quedar enterrado entre el resto.

jsonschema pasa a dependencia directa: ya entraba como transitiva de MCP, pero
ahora el código la importa.

135 tests en verde.

@g-garciac2022
g-garciac2022 merged commit 209199f into dev Aug 11, 2026
1 check passed
@g-garciac2022
g-garciac2022 deleted the feat/100-execute-y-contrato-retorno branch August 11, 2026 15:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[api] POST /tools/{name}/execute y contrato de retorno estructurado

1 participant