Skip to content

MCP stdio server: osw log records bypass the stdout guard #170

Closed as not planned

Description

@LukasGold

Once #160 and
#133 are both merged, osw log
records will be written to the JSON-RPC channel of the MCP stdio server.

Cause

  • PR feat(logging): replace print statements with the logging module #160 calls enable_logging() at import time in src/osw/__init__.py. Its
    default stream is sys.stdout and its default level is INFO.
  • The MCP server serves JSON-RPC over stdout
    (src/osw/mcp/server.py:170, mcp.run(transport="stdio")).
  • The existing protection does not cover logging. Context.guard() wraps each
    osw call in redirect_stdout(sys.stderr) when policy.capture_stdout is set
    (src/osw/service/context.py:113-125, enabled at src/osw/mcp/server.py:134).
    A logging.StreamHandler stores the stream object at construction time, so
    rebinding sys.stdout afterwards has no effect on it.

Reproduction of the last point, independent of osw:

import io
import logging
import sys
from contextlib import redirect_stdout

handler = logging.StreamHandler(sys.stdout)  # built before the redirect
logger = logging.getLogger("probe")
logger.addHandler(handler)
logger.setLevel(logging.INFO)
logger.propagate = False

buffer = io.StringIO()
with redirect_stdout(buffer):
    logger.info("does redirect_stdout capture this?")
print("captured by redirect:", repr(buffer.getvalue()))

The record is written to the real stdout and buffer stays empty.

Effect

Proposed fix

Redirect the osw logger to stderr in main() in src/osw/mcp/server.py, before
mcp.run():

osw.enable_logging(stream=sys.stderr)

osw.disable_logging() is the alternative, if the server should stay silent
unless the client configures logging itself.

Neither PR can carry this alone: enable_logging only exists in #160, and
src/osw/mcp/server.py only exists in #133. Whichever merges second should add
the call, plus a test asserting that a tool call writes nothing to stdout.

Related

The osw[wikitext] hint in src/osw/wiki_tools.py is the current instance of
the same pattern. It reaches stdout through print() today, but at import time
only, so it does not corrupt the stream. #160 converts it to a logger call,
which does not change its destination as long as the default handler writes to
stdout.

Activity

  1. LukasGold commented on Sep 7, 2026

    @LukasGold
    ContributorAuthor

    Resolved on the library side in #160 (commit 329bff4): enable_logging() in src/osw/__init__.py now defaults to sys.stderr, with test_the_default_handler_writes_to_stderr in tests/test_logging_setup.py guarding the default.

    This removes the conflict for every stdio consumer, so #133 needs no change and the "whichever merges second carries the fix" note above no longer applies.

    Ready to close once #160 is merged.

  2. LukasGold commented on Sep 21, 2026

    @LukasGold
    ContributorAuthor

    Closing as out of date. The diagnosis in this issue no longer matches the code.

    The issue states that enable_logging() defaults its handler stream to sys.stdout. On feat/mcp-server it defaults to sys.stderr (src/osw/__init__.py:149):

    handler = _DefaultHandler(sys.stderr if stream is None else stream)

    Its docstring names the MCP stdio server as the reason for that default, so the fix this issue proposed is in place, applied in the library rather than in main() of src/osw/mcp/server.py.

    There is also a second protection that was not known when this was written. The MCP SDK's stdio_server claims file descriptor 1 and diverts it, then re-wraps the original as UTF-8 text. A stray print() therefore cannot tear a JSON-RPC frame.

    The one item from this issue that is still outstanding is the test it asked for. No test asserts that a tool call writes nothing to stdout. That is tracked in #192.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions