# ¿Por qué mantener limpio el stdout de MCP protege la frontera del protocolo?

Una frontera stdio de MCP solo sigue siendo fiable cuando cada byte de stdout pertenece al protocolo y cada solicitud malformada o ambigua muere antes de llegar a un ejecutor de acciones. Un analizador que rechaza la basura, pero permite que una solicitud entendida a medias llegue a HTTP o SSH, ha fallado justo en el punto peligroso.

Los equipos suelen tratar el ruido de stdout como un molesto problema de interoperabilidad. Eso es demasiado indulgente. Cuando un agente puede provocar acciones externas, un mensaje adicional puede desincronizar la conversación, ocultar una respuesta de error o hacer que un cliente permisivo asocie la respuesta equivocada con la solicitud equivocada. La prueba correcta no se limita a comprobar que el proceso termina limpiamente. Demuestra que una entrada malformada en el cable no produce ningún efecto externo.

## El contrato de stdout acepta un mensaje JSON-RPC por línea

El transporte stdio del Model Context Protocol exige mensajes JSON-RPC en stdout, separados por saltos de línea, sin mezclar otra salida en ese flujo. La regla equivalente se aplica en la otra dirección: el cliente envía mensajes de protocolo por stdin y no usa ese canal como tubería de registros.

Parece obvio hasta que un instalador de paquetes imprime un aviso, una dependencia emite una advertencia o alguien deja un `print()` temporal en la ruta de inicio. En un terminal humano, esas líneas no hacen daño. En un flujo de protocolo, son bytes que el interlocutor debe interpretar. El interlocutor no tiene una forma segura de saber si `loading credentials` es un mensaje de inicio, un resultado malformado, un fragmento de una respuesta o el comienzo de un intercambio comprometido.

El contrato de transporte tiene tres partes que las pruebas deben declarar de forma explícita:

- Cada línea completa debe decodificarse como UTF-8 y analizarse como un único valor JSON.
- Ese valor debe tener la estructura permitida para una solicitud, respuesta o notificación JSON-RPC según la dirección del flujo.
- No debe comenzar ninguna acción hasta que la solicitud completa haya superado las comprobaciones de entramado, JSON, protocolo, esquema y autorización.

La primera condición detecta el ruido. La segunda detecta un objeto que resulta ser JSON válido, pero no un mensaje MCP. La tercera protege contra el error que importa: tratar el análisis inicial como permiso para ejecutar.

La especificación JSON-RPC 2.0 separa el error de análisis de la solicitud no válida. El JSON no válido puede recibir el código de error -32700 si el interlocutor todavía puede escribir una respuesta con la trama correcta. Un valor JSON con una estructura de solicitud incorrecta es una solicitud no válida, normalmente -32600. Esos códigos ayudan a un interlocutor conforme a diagnosticar el fallo. No indican si tu propio ejecutor permaneció intacto. Tus pruebas deben responder directamente a esa pregunta.

## Un mensaje de inicio puede corromper una respuesta autorizada

Un mensaje de inicio puede romper una acción perfectamente legítima después de que la autorización haya tenido éxito. Por eso la limpieza de stdout no es solo un asunto de validación de entrada.

Imagina un servidor que ha aceptado una solicitud de inicialización y está a punto de devolver el resultado de una herramienta. Una dependencia escribe `warning: configuration missing` en stdout entre la apertura y el cierre del ciclo de respuesta. Un cliente estricto rechaza la línea y se desconecta. Uno flexible la omite y continúa. El cliente estricto pierde disponibilidad. El cliente flexible adopta una política de análisis que acepta bytes ajenos dentro de un intercambio sensible para la seguridad.

No premies al cliente flexible por parecer cómodo. En cuanto un cliente descarta líneas arbitrarias, asume una larga lista de preguntas que no puede responder de forma fiable. ¿Descartó un diagnóstico? ¿Descartó una respuesta para otra solicitud? ¿Un envoltorio duplicó una línea? ¿Un atacante capaz de influir en el proceso hijo inyectó texto que cambia el estado del cliente? Un cliente no puede deducir la intención a partir de una secuencia de bytes suelta.

Mantén los diagnósticos en stderr. Dale a stderr sus propias reglas de captura, retención y ocultación de datos, y haz que la supervisión de procesos conserve la separación. Un fallo sorprendentemente común que solo aparece en las versiones publicadas procede de un envoltorio que combina ambos flujos porque la salida del terminal se veía mejor durante el desarrollo. Ese envoltorio destruye silenciosamente la frontera del protocolo.

Prueba el tráfico saliente como bytes sin procesar antes de que una biblioteca lo normalice. Si una biblioteca convierte una secuencia no válida en una excepción y oculta la transcripción original, conserva esa transcripción en la salida de la prueba fallida. Ver la primera línea incorrecta exacta ahorra horas de conjeturas.

## La denegación debe llegar al ejecutor de acciones

Una trama rechazada solo es segura si el código que realiza una acción externa nunca la ve. Devolver una respuesta de error resulta útil, pero no es la propiedad de seguridad.

Coloca un registrador de acciones justo detrás de la frontera final de validación y autorización. En una implementación real, ese punto puede envolver la función que abre una conexión HTTP o invoca un ayudante SSH. En una prueba, usa un registrador en memoria o un servicio falso local. Nunca dirijas las pruebas de entradas malformadas a un punto final real confiando en que la ruta de error te protegerá.

La diferencia se difumina fácilmente porque una solicitud normal recorre un camino largo. Llega como bytes, se convierte en JSON, luego en un objeto JSON-RPC, después en una llamada a un método MCP, se compara con un esquema de herramienta, obtiene una decisión de autorización y finalmente se convierte en una acción. Un desarrollador puede añadir un registro de auditoría o construir un objeto de solicitud antes de que terminen todas esas comprobaciones. Eso solo es aceptable si ninguna de las dos operaciones puede contactar con el exterior ni consumir una capacidad.

Un invariante útil es el siguiente: el ejecutor acepta un objeto de acción completamente tipado y autorizado, nunca JSON sin procesar ni una solicitud validada a medias. Si el ejecutor acepta un diccionario genérico, alguien acabará llamándolo demasiado pronto. El código puede superar las pruebas del camino feliz durante meses porque las tramas malformadas son poco frecuentes en el desarrollo habitual.

Mantén separadas las métricas de rechazos y las de ejecuciones. Una prueba debe poder afirmar que el analizador rechazó una trama, la sesión se cerró y el ejecutor recibió cero llamadas. Si esos eventos comparten un único contador general de éxito o fallo, no podrás distinguir una denegación limpia de una acción que comenzó y falló después.

## Coloca el punto de prueba debajo del análisis y encima de la ejecución

El arnés mínimo útil tiene un validador estricto del flujo y un ejecutor falso. El validador se ocupa de los bytes y de la estructura del protocolo. El ejecutor falso registra cada intento de realizar trabajo. El adaptador de producción puede ser diferente, pero el contrato entre ambos debe seguir siendo estrecho.

Este ejemplo en Python es deliberadamente pequeño. Guárdalo como `test_stdio_boundary.py`, instala pytest y ejecuta `pytest -q test_stdio_boundary.py`. Sustituye `Gateway` por un adaptador de tu propia pasarela, pero conserva las afirmaciones relacionadas con `Recorder`.

```python
import io
import json
import pytest

class Recorder:
    def __init__(self):
        self.calls = []

    def execute(self, action):
        self.calls.append(action)
        return {'ok': True}

class Gateway:
    def __init__(self, executor):
        self.executor = executor
        self.closed = False

    def reject(self, code, reason):
        self.closed = True
        return {'jsonrpc': '2.0', 'id': None,
                'error': {'code': code, 'message': reason}}

    def receive_line(self, raw_line):
        if self.closed:
            return None
        try:
            message = json.loads(raw_line)
        except json.JSONDecodeError:
            return self.reject(-32700, 'parse error')

        if not isinstance(message, dict):
            return self.reject(-32600, 'invalid request')
        if message.get('jsonrpc') != '2.0':
            return self.reject(-32600, 'invalid request')
        if message.get('method') != 'tools/call':
            return self.reject(-32601, 'method not found')
        if not isinstance(message.get('id'), (str, int)) or isinstance(message.get('id'), bool):
            return self.reject(-32600, 'invalid request')

        params = message.get('params')
        if not isinstance(params, dict):
            return self.reject(-32602, 'invalid params')
        if not isinstance(params.get('name'), str):
            return self.reject(-32602, 'invalid params')
        if not isinstance(params.get('arguments', {}), dict):
            return self.reject(-32602, 'invalid params')

        action = {'name': params['name'], 'arguments': params.get('arguments', {})}
        result = self.executor.execute(action)
        return {'jsonrpc': '2.0', 'id': message['id'], 'result': result}

def frame(value):
    return json.dumps(value, separators=(',', ':')) + '\n'


def test_bad_lines_never_execute():
    bad_lines = [
        'debug: entering tool handler\n',
        '\u003chtml\u003egateway unavailable\u003c/html\u003e\n',
        '{not json}\n',
        'null\n',
        frame({'jsonrpc': '1.0', 'id': 4, 'method': 'tools/call', 'params': {}}),
        frame({'jsonrpc': '2.0', 'id': 4, 'method': 'tools/call', 'params': 'run'}),
    ]

    for raw_line in bad_lines:
        recorder = Recorder()
        gateway = Gateway(recorder)
        response = gateway.receive_line(raw_line)
        assert response['jsonrpc'] == '2.0'
        assert 'error' in response
        assert gateway.closed
        assert recorder.calls == []

def test_complete_valid_request_executes_once():
    recorder = Recorder()
    gateway = Gateway(recorder)
    request = frame({
        'jsonrpc': '2.0',
        'id': 9,
        'method': 'tools/call',
        'params': {'name': 'safe-test', 'arguments': {'value': 'green'}},
    })

    response = gateway.receive_line(request)
    assert response['result'] == {'ok': True}
    assert recorder.calls == [{'name': 'safe-test', 'arguments': {'value': 'green'}}]
```

No es una implementación completa de MCP ni debe convertirse en una. Su objetivo es hacer ejecutable la propiedad de ausencia de acciones. Tu adaptador puede introducir bytes reales de stdin en el mismo tipo de punto de prueba y usar el validador real de solicitudes. No copies el manejo abreviado de métodos en un servidor de producción.

Observa la decisión de cerrar después de una línea de entrada malformada o no válida. El protocolo no obliga a todas las implementaciones a usar esa política de sesión. Es un valor predeterminado sensato cuando una pasarela controla efectos externos, porque una línea dañada puede significar que el interlocutor ha perdido el entramado. Si mantienes abierta la sesión después de una solicitud inválida, pero correctamente delimitada, prueba esa ruta por separado y demuestra que la siguiente solicitud válida no puede heredar ningún estado de la rechazada.

## Un arnés ejecutable debe observar los bytes y los efectos

La prueba unitaria anterior comprueba la denegación de entrada. Añade una prueba a nivel de proceso para detectar la salida que aparece fuera de la ruta de código que sueles ejercitar. El siguiente ayudante comprueba una transcripción capturada de stdout sin confiar en que una biblioteca cliente la analice por ti.

```python
import json
import subprocess

def assert_protocol_stdout(data):
    assert data.endswith(b'\n'), 'stdout ended without a complete frame'
    for number, raw_line in enumerate(data.splitlines(), start=1):
        try:
            line = raw_line.decode('utf-8')
            message = json.loads(line)
        except (UnicodeDecodeError, json.JSONDecodeError) as error:
            raise AssertionError(
                f'non-protocol stdout on line {number}: {raw_line!r}'
            ) from error

        assert isinstance(message, dict), f'line {number} is not an object'
        assert message.get('jsonrpc') == '2.0', f'line {number} lacks JSON-RPC 2.0'
        is_notification = isinstance(message.get('method'), str) and 'id' not in message
        is_response = 'id' in message and ('result' in message or 'error' in message)
        assert is_notification or is_response, f'line {number} has no permitted shape'

def run_server(command, stdin_bytes):
    completed = subprocess.run(
        command,
        input=stdin_bytes,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        check=False,
    )
    assert_protocol_stdout(completed.stdout)
    return completed

def test_release_command_has_clean_stdout():
    initialize = (
        b'{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",'
        b'\"params\":{\"protocolVersion\":\"2025-03-26\",'
        b'\"capabilities\":{},\"clientInfo\":{\"name\":\"boundary-test\",\"version\":\"1\"}}}\n'
    )
    completed = run_server(['./gateway-under-test'], initialize)
    assert completed.returncode == 0
```

Usa tu comando de lanzamiento real en lugar de `./gateway-under-test`. Si necesita un entorno de ejecución, un envoltorio de paquetes o variables de entorno, utiliza exactamente las condiciones de producción. La prueba debe inspeccionar stdout incluso cuando el proceso termina con un estado distinto de cero. Un proceso fallido todavía puede emitir un mensaje ilegal antes de fallar.

El fallo debe mostrar la línea de bytes sin procesar. Un resultado útil tiene este aspecto:

```
AssertionError: non-protocol stdout on line 1: b'loading optional extension\n'
```

Ese resultado indica al responsable dónde buscar. Un mensaje genérico como «el servidor no se inicializó» hace que la gente busque en el código del protocolo cuando el defecto puede estar en un script de shell, la configuración de registros o la importación de una dependencia.

Ejecuta el arnés con el formato empaquetado, no solo desde una copia del código fuente. El empaquetado cambia la resolución de módulos, las variables de entorno, los permisos de ejecución y las rutas de error. Es justo en esos lugares donde suelen aparecer escrituras accidentales en stdout.

## Prueba entradas feas, no solo errores educados

Una suite de pruebas de frontera necesita entradas que se parezcan a las formas en que fallan los flujos de procesos reales, no solo objetos inválidos escritos a mano. Introduce cada caso por el mismo punto de entrada que gestiona stdin y comprueba que el ejecutor siga vacío.

Usa al menos estos casos:

- Una línea de depuración antes de una solicitud válida, seguida de la solicitud válida en la línea siguiente.
- Una solicitud válida seguida de un mensaje en la misma línea, sin separador de salto de línea.
- Un objeto JSON truncado seguido del fin de archivo.
- Dos objetos JSON completos concatenados sin separador.
- Un objeto JSON válido con un método plausible, pero con parámetros malformados.

El primer caso detecta una implementación que omite silenciosamente las líneas incorrectas y continúa. El segundo detecta errores de entramado que un lector de líneas puede confundir con una única solicitud corrupta. El caso truncado detecta código que intenta reparar la entrada con un búfer después de que el interlocutor se haya desconectado. El caso concatenado detecta analizadores configurados para aceptar varios valores JSON de nivel superior cuando el transporte permite una trama por línea.

No reduzcas esta suite a un corpus de analizadores. Combina cada entrada incorrecta con un nombre de acción falso único y comprueba que ninguno aparezca en el registrador. Después incluye una solicitud válida tras una rechazada solo si tu política documentada permite mantener abierta la sesión. Si tu política cierra la sesión, afirma que la segunda solicitud no produce ninguna acción porque la sesión ya está cerrada.

Prueba también datos válidos que resultan peligrosos en contexto. Una solicitud con `method` igual a `tools/call` y `params` igual a una cadena es JSON válido, pero no una llamada. Una solicitud con un identificador numérico que tu lenguaje trata como booleano ha cruzado un límite de tipos que no pretendías cruzar. El esquema puede rechazar una solicitud con argumentos desconocidos, pero una combinación permisiva de objetos puede introducirlos accidentalmente en un generador de comandos posterior.

## El ruido de inicio tiene causas normales

La mayor parte de la contaminación de stdout procede de tareas normales de mantenimiento, no de alguien que intente vulnerar un protocolo. Eso no reduce la necesidad de detectarla.

Un envoltorio de comandos puede imprimir un aviso de versión. Un entorno de ejecución puede escribir una advertencia de obsolescencia después de un cambio de entorno. Un desarrollador puede dejar una instrucción de depuración en una importación de paquete que las pruebas no cargan. Un gestor de fallos puede imprimir un mensaje amable en stdout porque fue creado para una aplicación de terminal. Un supervisor puede combinar stderr con stdout como parte de la recopilación de registros.

Trata cada origen como un caso de prueba de lanzamiento. Configura variables de entorno que activen la salida detallada de las dependencias. Ejecuta el archivo ejecutable desde un directorio sin su configuración opcional. Fuerza un fallo de inicio recuperable. Ejercita la primera solicitud después de un inicio en reposo. Después comprueba la transcripción sin procesar cada vez.

No silencies todos los registros para que la prueba pase. Eso solo desplaza el problema operativo a otro lugar. Dirige los diagnósticos a stderr, ofrece a los operadores una forma explícita de recopilarlos y oculta los valores sensibles antes de que salgan del proceso. La corrección del protocolo y unos diagnósticos útiles pueden coexistir cuando los flujos permanecen separados.

## El JSON válido no demuestra que una solicitud sea segura

Un analizador JSON estricto protege el entramado. No decide si una solicitud puede usar credenciales o llegar a un host.

Mantén estas decisiones en orden. Primero, el transporte lee una trama. Después, el analizador crea un valor JSON. Luego, el validador del protocolo establece la estructura del método JSON-RPC y MCP. El validador del esquema comprueba los argumentos de la herramienta. Solo después de esas etapas la autorización debe decidir si la acción solicitada puede ejecutarse. El ejecutor debe recibir una acción tipada junto con esa decisión, no un nombre de método sin procesar y un diccionario de parámetros.

Este orden evita un fallo sutil: construir una solicitud HTTP mientras la validación todavía está en curso. Si una comprobación posterior rechaza la llamada, pero una biblioteca ya ha resuelto un host, abierto una conexión o expandido una plantilla de comandos, tu prueba puede informar de una denegación mientras la frontera ya ha dejado escapar trabajo. La prueba con el ejecutor falso detecta la forma evidente. Un servicio HTTP falso local o un ayudante de pruebas SSH puede detectar actividad de red accidental en las pruebas de integración.

Una solicitud rechazada puede pertenecer al registro de auditoría, pero no conviertas el registro en un canal lateral que invoque la capa de acciones. Registra el rechazo como un rechazo, con un motivo y una huella de solicitud que no exponga secretos. Mantén separado el diario de acciones externas de una llamada intentada que nunca superó la autorización.

## Una sola impresión accidental puede ocultar un fallo peligroso

Considera una pasarela compatible con una herramienta llamada `deploy-preview`. Su controlador valida algunos argumentos, empieza a preparar una solicitud saliente y después comprueba si la persona que llama puede usar la credencial seleccionada. Durante una refactorización, un desarrollador añade una impresión en stdout para inspeccionar el destino elegido.

Un cliente MCP estricto ve la impresión, no puede analizarla como JSON-RPC y se desconecta. El desarrollador ve un fallo de protocolo y corrige la impresión. Es molesto, pero seguro.

Un cliente flexible omite la línea, recibe una respuesta de error e informa al operador de que la autorización denegó la solicitud. Mientras tanto, el controlador ya había pasado la solicitud preparada a un ayudante HTTP con reintentos antes de comprobar la autorización. El servicio remoto recibe la solicitud sin una credencial utilizable, quizá devuelve un error, y deja al equipo con un registro de auditoría engañoso: parece que al agente se le denegó la acción, pero el servicio sí observó actividad.

El mensaje no creó el fallo de orden de autorización. Reveló por qué la recuperación flexible del transporte y la construcción anticipada de acciones forman una mala combinación. La solución no es una regla de omisión más inteligente. Mueve la decisión de autorización antes de construir la solicitud, usa el registrador para demostrarlo y mantén stdout tan estricto que cualquier salida accidental haga fallar la prueba de inmediato.

## Convierte el contrato del flujo en un criterio de lanzamiento

Coloca una prueba de stdout limpio junto a cada comando de pasarela empaquetado y ejecútala en integración continua. Haz que el fallo incluya los primeros bytes problemáticos, el comando de lanzamiento y stderr como adjunto independiente. El responsable debe poder reproducir el fallo sin reconstruir una conversación del agente.

Conserva bajo control de versiones un corpus pequeño de tramas de entrada malformadas. Añade un caso cada vez que aparezca un defecto real. Resiste la tentación de aceptar una rareza nueva solo porque un cliente la emitió. Si una implementación envía tráfico malformado, corrige la implementación o documenta una frontera de compatibilidad versionada que no permita acciones.

Sallyport coloca la ejecución HTTP y SSH detrás de su shim stdio integrado `sp mcp`, así que este arnés debe situarse en la frontera de ese shim y comprobar que una trama rechazada no pueda liberar una llamada externa. La misma disciplina se aplica a cualquier servidor MCP capaz de hacer algo más que devolver texto.

El criterio de lanzamiento es sencillo: stdout contiene únicamente mensajes de protocolo completos y una entrada rechazada no deja rastro en el registrador de acciones. Si falla cualquiera de las dos afirmaciones, la compilación no está lista para transportar tráfico de agentes.
