Por que manter o stdout do MCP limpo protege a fronteira do protocolo?
O tratamento limpo do stdout no MCP impede que logs, banners e frames malformados atravessem a fronteira de um gateway de agentes. Crie um harness que prove que as chamadas são interrompidas.

Uma fronteira MCP via stdio só permanece confiável quando cada byte no stdout pertence ao protocolo e toda solicitação malformada ou ambígua é interrompida antes de chegar a um executor de ações. Um analisador que rejeita lixo, mas permite que uma solicitação parcialmente compreendida chegue ao HTTP ou SSH falhou justamente no ponto mais perigoso.
As equipes costumam tratar o ruído no stdout como um problema irritante de interoperabilidade. Isso é ser condescendente demais. Quando um agente pode provocar ações externas, um banner extra pode dessincronizar a conversa, esconder uma resposta de erro ou levar um cliente permissivo a associar a resposta errada à solicitação errada. O teste certo faz mais do que verificar se o processo termina de forma limpa. Ele prova que uma entrada malformada no fio não produz nenhum efeito externo.
O contrato do stdout aceita uma mensagem JSON-RPC por linha
O transporte stdio do Model Context Protocol exige mensagens JSON-RPC no stdout, separadas por quebras de linha, sem nenhuma saída alheia misturada ao fluxo. A regra correspondente vale na outra direção: o cliente envia mensagens do protocolo pelo stdin e não usa esse canal como um fluxo de logs.
Isso parece óbvio até que um instalador de pacote imprima um aviso, uma dependência emita uma advertência ou alguém deixe um print() temporário em um caminho de inicialização. Em um terminal usado por pessoas, essas linhas são inofensivas. Em um fluxo de protocolo, são bytes que o outro lado precisa interpretar. Esse outro lado não tem uma maneira segura de saber se loading credentials é um banner, um resultado malformado, um fragmento de resposta ou o início de uma troca comprometida.
O contrato de transporte tem três partes que os testes devem declarar explicitamente:
- Cada linha completa precisa ser decodificada como UTF-8 e analisada como um único valor JSON.
- Esse valor precisa ter o formato permitido de uma solicitação, resposta ou notificação JSON-RPC para a direção do fluxo.
- Nenhuma ação pode começar antes que a solicitação completa passe pelas verificações de enquadramento, JSON, protocolo, esquema e autorização.
A primeira condição detecta ruído. A segunda detecta um objeto que por acaso é um JSON válido, mas não é uma mensagem MCP. A terceira protege contra o erro que realmente importa: tratar a análise inicial como permissão para executar.
A especificação JSON-RPC 2.0 separa um erro de análise de uma solicitação inválida. Um JSON inválido pode receber o código de erro -32700 se o outro lado ainda conseguir escrever uma resposta enquadrada. Um valor JSON com a estrutura de solicitação errada é uma solicitação inválida, normalmente -32600. Esses códigos ajudam um participante em conformidade a diagnosticar a falha. Eles não informam se o seu próprio executor permaneceu intocado. Os testes precisam responder a essa pergunta diretamente.
Um banner pode corromper uma resposta autorizada
Um banner de inicialização pode interromper uma ação perfeitamente legítima depois que a autorização foi concluída. Por isso, a limpeza do stdout não é apenas uma preocupação de validação de entrada.
Imagine um servidor que aceitou uma solicitação de inicialização e está prestes a retornar o resultado de uma ferramenta. Uma dependência escreve warning: configuration missing no stdout entre a abertura e o fechamento do ciclo de vida da resposta. Um cliente estrito rejeita a linha e se desconecta. Um cliente permissivo a ignora e continua. O cliente estrito perde disponibilidade. O cliente permissivo passa a ter uma política de análise que aceita bytes sem dono dentro de uma troca sensível à segurança.
Não recompense o cliente permissivo por parecer conveniente. Assim que um cliente descarta linhas arbitrárias, ele passa a lidar com uma longa lista de perguntas que não consegue responder de forma confiável. Ele descartou um diagnóstico? Descartou a resposta de outra solicitação? Um wrapper duplicou uma linha? Um invasor que consegue influenciar o processo filho injetou um texto capaz de alterar o estado do cliente? Um cliente não consegue deduzir a intenção a partir de uma sequência de bytes solta.
Mantenha os diagnósticos no stderr. Dê ao stderr suas próprias regras de captura, retenção e mascaramento, e faça a supervisão do processo preservar essa separação. Uma falha surpreendentemente comum, que só aparece no release, vem de um wrapper que combina os dois fluxos porque a saída do terminal parecia mais bonita durante o desenvolvimento. Esse wrapper destrói silenciosamente a fronteira do protocolo.
Teste o tráfego de saída como bytes brutos antes que qualquer biblioteca de cliente o normalize. Se uma biblioteca transformar uma sequência inválida em uma exceção e esconder a transcrição original, preserve essa transcrição na saída do teste que falhou. Ver exatamente a primeira linha problemática poupa horas de suposições.
A negação precisa chegar ao executor da ação
Um frame rejeitado só é seguro se o código que executa uma ação externa nunca o enxergar. Retornar uma resposta de erro é útil, mas não é a propriedade de segurança.
Coloque um gravador de ações imediatamente depois da fronteira final de validação e autorização. Em uma implementação real, essa junção pode envolver a função que abre uma conexão HTTP ou chama um helper SSH. Em um teste, use um gravador em memória ou um serviço falso local. Nunca aponte testes de entradas malformadas para um endpoint real confiando que o caminho de erro vai protegê-lo.
A distinção é fácil de perder de vista porque uma solicitação normal percorre um caminho longo. Ela chega como bytes, vira JSON, depois um objeto JSON-RPC, depois uma chamada de método MCP, é associada ao esquema de uma ferramenta, recebe uma decisão de autorização e finalmente se torna uma ação. Um desenvolvedor pode adicionar um registro de auditoria ou montar um objeto de solicitação antes que todas essas verificações terminem. Isso só é aceitável se nenhuma dessas operações puder acessar o mundo externo ou consumir uma capacidade.
Uma invariante útil é esta: o executor aceita um objeto de ação totalmente tipado e autorizado, nunca JSON bruto nem uma solicitação parcialmente validada. Se o executor aceita um dicionário genérico, alguém acabará chamando-o cedo demais. O código pode passar nos testes do caminho feliz durante meses, porque frames malformados são raros no desenvolvimento normal.
Mantenha separadas as métricas de rejeição e de execução. Um teste deve conseguir afirmar que o analisador rejeitou um frame, a sessão foi encerrada e o executor recebeu zero chamadas. Se esses eventos compartilharem um único contador amplo de sucesso ou falha, você não conseguirá distinguir uma negação limpa de uma ação que começou e falhou depois.
Coloque a junção de teste abaixo da análise e acima da execução
O menor harness útil tem um validador rígido do fio e um executor falso. O validador é responsável pelos bytes e pelo formato do protocolo. O executor falso registra cada tentativa de realizar um trabalho. O adaptador de produção pode ser diferente, mas o contrato entre os dois deve continuar estreito.
Este exemplo em Python é propositalmente pequeno. Salve-o como test_stdio_boundary.py, instale o pytest e execute pytest -q test_stdio_boundary.py. Substitua Gateway por um adaptador para o seu próprio gateway, preservando as asserções em torno de Recorder.
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'}}]
Esta não é uma implementação MCP completa e não deve se tornar uma. O objetivo é tornar executável a propriedade de que nenhuma ação ocorre. Seu adaptador pode alimentar bytes reais do stdin no mesmo tipo de junção de teste e usar o validador de solicitações real. Não copie o tratamento abreviado de métodos para um servidor de produção.
Observe a escolha de fechar depois de uma linha de entrada malformada ou inválida. O protocolo não obriga toda implementação a usar essa política de sessão. É um padrão sensato quando um gateway controla efeitos externos, porque uma linha quebrada pode significar que o outro lado perdeu o enquadramento. Se você mantiver a sessão aberta depois de uma solicitação inválida, mas corretamente enquadrada, teste esse caminho separadamente e prove que a próxima solicitação válida não herda nenhum estado da solicitação rejeitada.
Um harness executável deve observar bytes e efeitos
O teste unitário acima verifica a negação de entrada. Acrescente um teste no nível do processo para detectar saídas que aparecem fora do caminho de código exercitado normalmente. O helper a seguir verifica uma transcrição capturada do stdout sem confiar que uma biblioteca de cliente fará a análise por você.
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
Use o comando de inicialização real no lugar de ./gateway-under-test. Se ele exigir um runtime, um wrapper de pacote ou variáveis de ambiente, use exatamente as condições de produção. O teste precisa inspecionar o stdout mesmo quando o processo termina com um status diferente de zero. Um processo com falha ainda pode emitir um banner ilegal antes de falhar.
A falha deve mostrar a linha bruta em bytes. Uma saída útil seria:
AssertionError: non-protocol stdout on line 1: b'loading optional extension\n'
Esse resultado mostra ao responsável pela manutenção onde procurar. Uma mensagem genérica como «o servidor não foi inicializado» faz as pessoas procurarem no código do protocolo quando o defeito pode estar em um script de shell, na configuração de logs ou na importação de uma dependência.
Execute o harness na forma empacotada, não apenas em um checkout do código-fonte. O empacotamento altera a resolução de módulos, as variáveis de ambiente, as permissões do executável e os caminhos de erro. Esses são exatamente os lugares onde aparecem escritas acidentais no stdout.
Teste entradas problemáticas, não apenas erros educados
Uma suíte de testes de fronteira precisa de entradas que imitem as formas como os fluxos de processos falham na prática, não apenas objetos inválidos escritos à mão. Envie cada caso pelo mesmo ponto de entrada que trata o stdin e confirme que o executor continua vazio.
Use pelo menos estes casos:
- Uma linha simples de depuração antes de uma solicitação válida, seguida da solicitação válida na linha seguinte.
- Uma solicitação válida seguida de um banner na mesma linha, sem separador de quebra de linha.
- Um objeto JSON truncado seguido pelo fim do arquivo.
- Dois objetos JSON completos concatenados sem separador.
- Um objeto JSON válido com um método plausível, mas parâmetros malformados.
O primeiro caso detecta uma implementação que ignora silenciosamente linhas inválidas e continua. O segundo detecta falhas de enquadramento que um leitor de linhas pode confundir com uma única solicitação corrompida. O caso truncado detecta código que tenta corrigir a entrada com um buffer depois que o outro lado desapareceu. O caso concatenado detecta analisadores configurados para aceitar vários valores JSON de nível superior quando o transporte permite um frame por linha.
Não reduza essa suíte a um corpus de analisadores. Associe cada entrada inválida a um nome exclusivo de ação falsa e confirme que nenhum deles aparece no gravador. Depois, inclua uma solicitação válida após uma solicitação rejeitada somente se a sua política documentada permitir que a sessão continue aberta. Se a política fechar a sessão, confirme que a segunda solicitação não produz nenhuma ação porque a sessão já está encerrada.
Teste também dados válidos que são perigosos no contexto. Uma solicitação com method definido como tools/call e params definido como uma string é um JSON válido, mas não é uma chamada. Uma solicitação com um id numérico que sua linguagem trata como booleano atravessou um limite de tipos que você não pretendia cruzar. Uma solicitação com argumentos desconhecidos pode ser rejeitada pelo esquema, mas uma combinação permissiva de objetos pode passá-los acidentalmente a um construtor de comandos downstream.
O ruído de inicialização tem causas comuns
A maior parte da contaminação do stdout vem de tarefas normais de manutenção, não de alguém tentando atacar um protocolo. Isso não diminui a necessidade de detectá-la.
Um wrapper de comando pode imprimir um aviso de versão. Um runtime pode escrever uma advertência de descontinuação depois de uma mudança no ambiente. Um desenvolvedor pode deixar uma instrução de depuração em uma importação de pacote que os testes não carregam. Um manipulador de falhas pode imprimir uma mensagem amigável no stdout porque foi criado para uma aplicação de terminal. Um supervisor pode combinar stderr e stdout como parte da coleta de logs.
Trate cada fonte como um caso de teste de release. Defina variáveis de ambiente que ativem a saída detalhada das dependências. Execute o binário em um diretório sem sua configuração opcional. Force uma falha recuperável na inicialização. Exercite a primeira solicitação depois de uma inicialização ociosa. Depois, verifique a transcrição bruta todas as vezes.
Não silencie todos os logs para fazer o teste passar. Isso apenas desloca o problema operacional para outro lugar. Direcione os diagnósticos para o stderr, ofereça aos operadores uma maneira explícita de coletá-los e mascare valores sensíveis antes que saiam do processo. Correção do protocolo e diagnósticos úteis podem coexistir quando os fluxos permanecem separados.
JSON válido não prova que uma solicitação é segura
Um analisador JSON rigoroso protege o enquadramento. Ele não decide se uma solicitação pode usar credenciais ou alcançar um host.
Mantenha essas decisões nesta ordem. Primeiro, o transporte lê um frame. Em seguida, o analisador produz um valor JSON. Depois, o validador do protocolo confirma o formato do método JSON-RPC e MCP. O validador de esquema verifica os argumentos da ferramenta. Só depois dessas etapas a autorização deve decidir se a ação solicitada pode ser executada. O executor deve receber uma ação tipada acompanhada dessa decisão, não um nome de método bruto e um dicionário de parâmetros.
Essa ordem evita uma falha sutil: montar uma solicitação HTTP enquanto a validação ainda está em andamento. Se uma verificação posterior rejeitar a chamada, mas uma biblioteca já tiver resolvido um host, aberto uma conexão ou expandido um modelo de comando, o teste poderá registrar uma negação enquanto a fronteira já deixou o trabalho escapar. O teste com um executor falso detecta a forma mais óbvia. Um serviço HTTP falso local ou um helper de teste SSH pode detectar atividade de rede acidental nos testes de integração.
Uma solicitação rejeitada ainda pode pertencer a um registro de auditoria, mas não transforme o registro em um canal lateral que chama a camada de ações. Registre a rejeição como rejeição, com um motivo e uma impressão digital da solicitação que não exponha segredos. Mantenha o diário de ações externas separado de uma chamada tentada que nunca passou pela autorização.
Uma única instrução print acidental pode esconder uma falha perigosa
Considere um gateway compatível com uma ferramenta chamada deploy-preview. Seu handler valida alguns argumentos, começa a montar uma solicitação de saída e depois verifica se o chamador pode usar a credencial selecionada. Durante uma refatoração, um desenvolvedor adiciona uma instrução print no stdout para inspecionar o destino selecionado.
Um cliente MCP estrito vê o print, não consegue analisá-lo como JSON-RPC e se desconecta. O desenvolvedor vê uma falha de protocolo e corrige o print. Isso é inconveniente, mas seguro.
Um cliente permissivo ignora a linha, recebe uma resposta de erro e informa ao operador que a autorização negou a solicitação. Enquanto isso, o handler já havia passado a solicitação montada a um helper HTTP com novas tentativas antes da verificação de autorização. O serviço remoto recebe a solicitação sem uma credencial utilizável, talvez retorne um erro e deixe a equipe com um registro de auditoria enganoso: o agente parece ter sido negado, mas o serviço ainda viu atividade.
O banner não criou o erro na ordem da autorização. Ele mostrou por que a recuperação permissiva do transporte e a construção antecipada de ações formam uma combinação perigosa. A solução não é uma regra de ignorar mais inteligente. Mova a decisão de autorização para antes da construção da solicitação, use o gravador para provar isso e mantenha o stdout tão rigoroso que qualquer saída acidental faça o teste falhar imediatamente.
Transforme o contrato do fio em uma barreira de release
Coloque um teste de stdout limpo ao lado de cada comando de gateway empacotado e execute-o na integração contínua. Faça a falha incluir os primeiros bytes problemáticos, o comando de inicialização e o stderr como um anexo separado. O responsável pela manutenção deve conseguir reproduzir a falha sem reconstruir uma conversa com o agente.
Mantenha sob controle de versão um pequeno corpus de frames de entrada malformados. Acrescente um caso sempre que surgir um defeito real. Resista à tentação de aceitar uma nova peculiaridade apenas porque um cliente a emitiu. Se uma implementação envia tráfego malformado, corrija a implementação ou documente uma fronteira de compatibilidade versionada que não permita ações.
O Sallyport coloca a execução HTTP e SSH atrás do shim stdio integrado sp mcp, então este harness deve ficar na fronteira desse shim e confirmar que um frame rejeitado não consegue liberar uma chamada externa. A mesma disciplina vale para qualquer servidor MCP capaz de fazer mais do que retornar texto.
O critério de release é simples: o stdout contém apenas mensagens completas do protocolo, e uma entrada rejeitada não deixa nenhum registro no gravador de ações. Se qualquer uma das asserções falhar, a build não está pronta para transportar tráfego de agentes.
FAQ
Um servidor MCP pode imprimir uma mensagem de inicialização no stdout?
Não. No transporte stdio, o stdout carrega os frames do protocolo, então um banner equivale a JSON corrompido. Envie diagnósticos para o stderr ou para um sistema de logs estruturados separado.
O que um gateway MCP deve fazer com JSON malformado?
Trate isso como uma falha na fronteira de transporte e confirme que nenhuma ação chegou ao executor. A decisão sobre retornar um erro de análise JSON-RPC antes de fechar o processo é do produto, mas o servidor nunca deve tentar adivinhar o significado da linha.
Qual erro JSON-RPC se aplica a JSON inválido?
O JSON-RPC 2.0 define o código de erro de análise -32700 para JSON inválido. Esse erro descreve o problema da mensagem, mas não prova que sua implementação interrompeu a operação HTTP ou SSH seguinte. Por isso, os testes precisam verificar os efeitos diretamente.
JSON válido é suficiente para permitir uma chamada de ferramenta MCP?
Não. Uma mensagem pode ser analisada corretamente e ainda conter a versão errada do protocolo, um método não compatível, um id de solicitação inválido ou argumentos que não correspondem ao esquema da ação. A validação da mensagem e a autorização da ação são etapas separadas.
Devo testar o stdio do MCP com testes unitários ou de integração?
Exercite o caminho completo dos bytes em testes de integração e use testes unitários para cobrir o analisador e a fronteira da ação com rapidez. Os testes unitários, sozinhos, costumam não detectar uma biblioteca, um wrapper ou um perfil de shell que escreva um banner antes de o código começar.
Como detectar ruído no stdout que só aparece em produção?
Execute o servidor com o mesmo ambiente, wrapper de shell, empacotamento e comando de inicialização usados em produção. Um terminal local limpo prova muito pouco se a versão instalada acrescenta uma mensagem, um aviso ou uma linha de progresso.
O stderr é seguro para os logs de um servidor MCP?
O stderr é separado do fluxo do protocolo stdio, portanto é o destino normal para logs e diagnósticos. Mantenha esse fluxo disponível para os operadores, mas evite combinar stderr com stdout em supervisores de processos ou executores de testes.
O que um harness de teste para MCP stdio deve verificar?
Ele deve provar duas coisas: que cada linha do stdout tem um formato JSON-RPC permitido e que toda entrada rejeitada deixa o gravador de ações vazio. Verificar apenas a transcrição pode deixar passar justamente a parte perigosa.
Um cliente MCP deve tentar se recuperar de uma linha inesperada no stdout?
Faça a ambiguidade falhar de forma segura. Erros de análise, bytes extras que não sejam espaços em branco, frames duplicados quando se espera apenas um e entradas incompletas devem encerrar a sessão ou a solicitação antes que qualquer chamada chegue ao executor.
Como testar chamadas MCP negadas sem arriscar credenciais reais?
Use um endpoint falso local ou um gravador que não possa acessar serviços reais e confirme que sua lista de chamadas continua vazia. Não teste entradas malformadas com credenciais ativas só porque a ação deveria ser negada.