# Why does keeping MCP stdout clean protect the protocol boundary?

A stdio MCP boundary stays trustworthy only when every byte on stdout belongs to the protocol and every malformed or ambiguous request dies before it reaches an action executor. A parser that rejects junk but lets a partially understood request reach HTTP or SSH has failed at the dangerous point.

Teams often treat stdout noise as an annoying interoperability bug. That is too charitable. When an agent can cause external actions, an extra banner can desynchronize the conversation, conceal an error response, or persuade a forgiving client to associate the wrong response with the wrong request. The right test does more than check that the process exits cleanly. It proves that malformed wire input produces no external effect.

## The stdout contract accepts one JSON-RPC message per line

The Model Context Protocol stdio transport requires JSON-RPC messages on stdout, separated by newlines, with no unrelated output mixed into that stream. The matching rule applies in the other direction: a client sends protocol messages on stdin and does not use that channel as a log pipe.

That sounds obvious until a package installer prints a notice, a dependency emits a warning, or someone drops a temporary `print()` into a startup path. On a human terminal, those lines are harmless. On a protocol stream, they are bytes a peer must interpret. The peer has no safe way to know whether `loading credentials` is a banner, a malformed result, a fragment of a response, or the beginning of a compromised exchange.

The transport contract has three parts that tests should state explicitly:

- Each complete line must decode as UTF-8 and parse as one JSON value.
- That value must be a permitted JSON-RPC request, response, or notification shape for the direction of travel.
- No action may begin until the complete request passes framing, JSON, protocol, schema, and authorization checks.

The first condition catches noise. The second catches an object that happens to be valid JSON but is not an MCP message. The third protects against the mistake that matters: treating early parsing as permission to execute.

The JSON-RPC 2.0 specification separates a parse error from an invalid request. Invalid JSON may receive error code -32700 if the peer can still write a framed response. A JSON value with the wrong request structure is an invalid request, normally -32600. Those codes help a conforming peer diagnose the failure. They do not tell you whether your own executor remained untouched. Your tests must answer that question directly.

## A banner can corrupt an authorized response

A startup banner can break a perfectly legitimate action after authorization succeeded. That is why stdout cleanliness is not only an inbound-validation concern.

Picture a server that has accepted an initialize request and is about to return a tool result. A dependency writes `warning: configuration missing` to stdout between the response's opening and closing lifecycle. A strict client rejects the line and disconnects. A loose client skips it and continues. The strict client loses availability. The loose client now has a parser policy that accepts unowned bytes inside a security-sensitive exchange.

Do not reward the loose client for appearing convenient. Once a client discards arbitrary lines, it owns a long list of questions it cannot answer reliably. Did it discard a diagnostic? Did it discard a response for another request? Did a wrapper duplicate a line? Did an attacker who can influence the child process inject text that changes client state? A client cannot infer intent from a stray byte sequence.

Keep diagnostics on stderr. Give stderr its own capture, retention, and redaction rules, then make process supervision preserve the separation. A surprisingly common release-only fault comes from a wrapper that combines both streams because terminal output looked nicer during development. That wrapper quietly destroys the protocol boundary.

Test outbound traffic as raw bytes before any client library normalizes it. If a library converts an invalid sequence into an exception and hides the original transcript, retain the transcript in the failing test output. Seeing the exact first bad line saves hours of guessing.

## Denial must reach the action executor

A rejected frame is safe only if the code that performs an external action never sees it. Returning an error response is useful, but it is not the safety property.

Put an action recorder immediately behind the final validation and authorization boundary. In a real implementation, that seam may wrap the function that opens an HTTP connection or invokes an SSH helper. In a test, use an in-memory recorder or a local fake service. Never point malformed-input tests at a real endpoint and trust the error path to save you.

The distinction is easy to blur because a normal request has a long route. It arrives as bytes, becomes JSON, becomes a JSON-RPC object, becomes an MCP method call, gets matched to a tool schema, gains an authorization decision, and finally becomes an action. A developer may add an audit record or build a request object before all of those checks finish. That is acceptable only if neither operation can contact the outside world or consume a capability.

A useful invariant is this: the executor accepts a fully typed, authorized action object, never raw JSON and never a partly validated request. If the executor accepts a generic dictionary, somebody will eventually call it too early. The code may pass happy-path tests for months because malformed frames are rare in ordinary development.

Keep rejection accounting separate from execution accounting. A test should be able to say that a parser rejected one frame, the session closed, and the executor received zero calls. If those events share one broad success or failure counter, you cannot distinguish a clean denial from an action that started and failed later.

## Put the test seam below parsing and above execution

The smallest useful harness has a strict wire validator and a fake executor. The validator owns bytes and protocol shape. The fake executor records every attempt to perform work. The production adapter can differ, but the contract between them should remain narrow.

This Python example is deliberately small. Save it as `test_stdio_boundary.py`, install pytest, and run `pytest -q test_stdio_boundary.py`. Replace `Gateway` with an adapter to your own gateway while preserving the assertions around `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',
        '<html>gateway unavailable</html>\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'}}]
```

This is not a complete MCP implementation, and it should not become one. Its purpose is to make the non-action property executable. Your adapter can feed actual stdin bytes into the same kind of test seam and use the real request validator. Do not copy the abbreviated method handling into a production server.

Notice the choice to close after a malformed or invalid inbound line. The protocol does not force every implementation to use that session policy. It is a sensible default when a gateway controls external side effects, because a broken line may mean that the peer has lost framing. If you keep the session open after a cleanly framed invalid request, test that path separately and prove that the next valid request cannot inherit any state from the rejected one.

## A runnable harness should observe bytes and effects

The unit test above checks inbound denial. Add a process-level test to catch output that appears outside the code path you usually exercise. The following helper checks a captured stdout transcript without trusting a client library to parse it for you.

```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
```

Use your actual launch command in place of `./gateway-under-test`. If it requires a runtime, a package wrapper, or environment variables, use those exact production conditions. The test must inspect stdout even when the process exits with a nonzero status. A failed process can still emit an illegal banner before it fails.

A failure should expose the raw byte line. A useful output looks like this:

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

That result tells the maintainer where to look. A generic message such as server did not initialize sends people hunting through protocol code when the defect may live in a shell script, logging configuration, or dependency import.

Run the harness in the packaged form, not only from a source checkout. Packaging changes module resolution, environment variables, executable permissions, and error paths. Those are exactly the places where accidental stdout writes appear.

## Test ugly inputs instead of polite errors

A boundary suite needs inputs that resemble the ways real process streams fail, not only hand-written invalid objects. Feed each case through the same entry point that handles stdin and assert that the executor remains empty.

Use at least these cases:

- A plain debug line before a valid request, followed by the valid request on the next line.
- A valid request followed by a banner on the same line, without a newline separator.
- A truncated JSON object followed by end of file.
- Two complete JSON objects concatenated with no separator.
- A valid JSON object with a plausible method but malformed parameters.

The first case catches an implementation that silently skips bad lines and carries on. The second catches framing bugs that a line reader can mistake for one corrupted request. The truncated case catches code that attempts to repair input with a buffer after the peer has gone away. The concatenated case catches parsers configured to accept multiple top-level JSON values where the transport allows one frame per line.

Do not reduce this suite to a parser corpus. Pair every bad input with a unique fake action name and verify that none appears in the recorder. Then include one valid request after a rejected request only if your documented policy allows the session to remain open. If your policy closes, assert that the second request produces no action because the session is already closed.

Also test valid data that is dangerous in context. A request with `method` set to `tools/call` and `params` set to a string is valid JSON but not a call. A request with a numeric id that your language treats as a boolean has crossed a type boundary you did not intend. A request with unknown arguments may be rejected by the schema, but a permissive object merger can accidentally pass them into a downstream command builder.

## Startup noise has ordinary causes

Most stdout contamination comes from normal maintenance work, not from somebody trying to defeat a protocol. That does not reduce the need to catch it.

A command wrapper may print a version notice. A language runtime may write a deprecation warning after an environment change. A developer may leave a debug statement in a package import that tests do not load. A crash handler may print a friendly message to stdout because it was built for a terminal application. A supervisor may merge stderr into stdout as part of log collection.

Treat each source as a release test case. Set environment variables that turn on verbose dependency output. Run the executable from a directory without its optional configuration. Force a recoverable startup failure. Exercise the first request after idle startup. Then check the raw transcript every time.

Do not silence all logs to make the test pass. That only moves the operational problem elsewhere. Route diagnostics to stderr, give operators an explicit way to collect them, and redact sensitive values before they leave the process. Protocol correctness and useful diagnostics can coexist when the streams remain separate.

## Valid JSON does not prove a safe request

A strict JSON parser protects framing. It does not decide whether a request may use credentials or reach a host.

Keep these decisions in order. First, the transport reads one frame. Next, the parser makes one JSON value. Then the protocol validator establishes the JSON-RPC and MCP method shape. The schema validator checks tool arguments. Only after those stages should authorization decide whether the requested action can run. The executor should receive a typed action plus that decision, not a raw method name and parameter dictionary.

This order prevents a subtle failure: building an HTTP request while validation is still underway. If a later check rejects the call but a library has already resolved a host, opened a connection, or expanded a command template, your test can report denial while the boundary has already leaked work. The fake executor test detects the obvious form. A local fake HTTP service or SSH test helper can detect accidental network activity in integration tests.

A rejected request may still belong in an audit record, but do not turn audit recording into a side channel that invokes the action layer. Record the rejection as a rejection with a reason and a request fingerprint that cannot expose secrets. Keep the external-action journal distinct from an attempted call that never crossed authorization.

## One accidental print can hide a dangerous failure

Consider a gateway that supports a tool named `deploy-preview`. Its handler validates some arguments, starts assembling an outbound request, then checks whether the caller may use the selected credential. During a refactor, a developer adds a stdout print to inspect the selected target.

A strict MCP client sees the print, cannot parse it as JSON-RPC, and disconnects. The developer sees a protocol failure and fixes the print. That is inconvenient but safe.

A permissive client skips the line, receives an error response, and tells the operator that authorization denied the request. Meanwhile, the handler had already passed the assembled request to a retrying HTTP helper before the authorization check. The remote service receives the request without a usable credential, perhaps returns an error, and leaves the team with a misleading audit trail: the agent appears to have been denied, yet the service still saw activity.

The banner did not create the authorization ordering bug. It exposed why forgiving transport recovery and early action construction make a bad pair. The remedy is not a smarter skip rule. Move the authorization decision ahead of request construction, use the recorder to prove it, and keep stdout so strict that accidental output fails the test immediately.

## Make the wire contract a release gate

Put a clean-stdout test beside every packaged gateway command and run it in continuous integration. Make the failure include the first offending bytes, the launch command, and stderr as a separate attachment. A maintainer should be able to reproduce the failure without reconstructing an agent conversation.

Keep a small corpus of malformed inbound frames under version control. Add a case whenever a real defect appears. Resist the urge to accept a new oddity merely because one client emitted it. If an implementation sends malformed traffic, fix the implementation or document a versioned compatibility boundary that does not permit actions.

Sallyport puts HTTP and SSH execution behind its bundled `sp mcp` stdio shim, so this harness belongs at that shim's boundary and should assert that a rejected frame cannot release an external call. The same discipline applies to any MCP server that can do more than return text.

The release criterion is plain: stdout contains only complete protocol messages, and rejected input leaves no trace in the action recorder. If either assertion fails, the build is not ready to carry agent traffic.
