Skip to content

fix: stop writing to stdout, which carries the stdio JSON-RPC protocol - #359

Merged
Jeomon merged 1 commit into
mainfrom
fix/stdout-writes-corrupt-stdio-protocol
Aug 1, 2026
Merged

fix: stop writing to stdout, which carries the stdio JSON-RPC protocol#359
Jeomon merged 1 commit into
mainfrom
fix/stdout-writes-corrupt-stdio-protocol

Conversation

@Jeomon

@Jeomon Jeomon commented Aug 1, 2026

Copy link
Copy Markdown
Member

The default transport is stdio, where stdout is the JSON-RPC channel. __main__.py:189 already says as much — "Written to stderr because stdout carries the stdio protocol" — but two live code paths write to it anyway, interleaving plain text into the framing the client is parsing.

tree/service.py

for box in boxes:
    word_elements.append((word, box))
    print(word, box)              # <- removed

This runs inside tree_traversal, for every EditControl / DocumentControl / ImageControl encountered, once per word. A single text field can emit dozens of lines into the protocol stream; a document control can emit hundreds.

It looks like leftover debugging from the word-bounding-box work — the value is already accumulated on the line above, and the print does nothing else. The enclosing try/except Exception: pass doesn't help, since the write has already happened by the time anything could raise.

infrastructure/analytics.py

# Using print for immediate visibility in console during debugging
print(f"[Analytics] {tool_name}: {success_mark} ({duration}ms)")
logger.info(f"{tool_name}: {success_mark} ({duration}ms)")   # same text, already

The line immediately below sends the identical message to the logger, which writes to stderr. Removing the print loses nothing.

Regression guard

tests/test_no_stdout_writes.py statically scans the source tree with ast. Design notes:

  • counts only ast.Call nodes, so prints inside docstring examples (controls.py:486, patterns.py:1587, and others) are excluded for free — they're strings, not calls;
  • ignores print(..., file=...), which isn't a stdout write;
  • no Windows imports, so it runs anywhere.

Two narrow exemptions are encoded rather than blanket-skipping any file:

exemption why
prints lexically inside an if ...debug...: block opt-in, off by default, never hit in normal operation
threadFunc inside RunByHotKey upstream uiautomation hotkey utility the server never invokes

Together those cover the three remaining prints in uia/controls.py (5421, 5442, 5912). I verified the exemptions aren't over-broad by injecting an ungated print into GetWordBoundingBoxAtPoint in that same file — still caught. Reintroducing the exact removed line at service.py:571 is also caught.

Testing caveat

I could not run the suite locally. pywin32 has no macOS wheels, so uv sync fails outright on this machine. Verification was:

  • static AST analysis of all 65 source files → 0 offenders after the fix;
  • direct execution of the scanner's functions, including mutation checks in both directions (regression caught, exemptions narrow, file=sys.stderr correctly ignored).

CI runs on windows-latest and will exercise the real suite. Worth a reviewer with a Windows box confirming the protocol noise is actually gone from a snapshot on an editable control — that's the observable symptom I couldn't reproduce from here.

The default transport is stdio, where stdout is the JSON-RPC channel --
as __main__.py already notes: "Written to stderr because stdout carries
the stdio protocol". Two live code paths wrote to it anyway, interleaving
plain text into the framing the client is parsing.

tree/service.py printed every word of every EditControl, DocumentControl
and ImageControl encountered during traversal:

    word_elements.append((word,box))
    print(word,box)                   # <- removed

This runs on any snapshot containing an editable control, once per word,
so a single text field can emit dozens of lines into the protocol stream.
It is debug leftover from the word-bounding-box work: the value is
already accumulated on the line above and the print does nothing else.

infrastructure/analytics.py printed a per-tool-call summary that the very
next line already sends to the logger, so removing the print loses
nothing -- the logger writes to stderr, where this belongs.

Adds tests/test_no_stdout_writes.py, a static AST scan of the source
tree. It counts only ast.Call nodes, so prints inside docstring examples
are excluded for free, and it ignores print(..., file=...) since that is
not a stdout write. Two narrow exemptions are encoded: prints lexically
inside an `if ...debug...:` block, and threadFunc inside RunByHotKey, the
upstream uiautomation hotkey utility the server never invokes. Those
cover the three remaining prints in uia/controls.py; verified that an
ungated print added anywhere in that same file is still caught.

Not run locally: the test suite needs pywin32, which has no macOS wheels,
so this was verified by static analysis and by exercising the scanner
directly. CI runs on windows-latest.
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Stop stdout writes that corrupt the stdio JSON-RPC channel

🐞 Bug fix 🧪 Tests 🕐 10-20 Minutes

Grey Divider

AI Description

• Remove two live print() calls that interleaved text into the stdio JSON-RPC stream.
• Keep runtime visibility via existing logger output (stderr) instead of stdout.
• Add an AST-based pytest to prevent reintroducing bare stdout prints.
Diagram

graph TD
  C{{"MCP host/client"}} --> S(["windows-mcp server"]) --> O[("stdout (JSON-RPC)")]
  S --> E[("stderr (logs)")]
  S --> T[/"tree_traversal"/] --> E
  S --> A[/"PostHogAnalytics"/] --> E
  S --> G["AST stdout guard test"] --> S

  subgraph Legend
    direction LR
    _ext{{"External"}} ~~~ _svc(["Service"]) ~~~ _mod[/"Module"/] ~~~ _db[("Stream")]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Redirect prints to stderr instead of removing
  • ➕ Preserves ad-hoc debugging output while keeping stdout clean
  • ➕ Minimal code change per callsite (e.g., print(..., file=sys.stderr))
  • ➖ Encourages continued use of print rather than structured logging
  • ➖ Still risks future protocol corruption if a print forgets file=stderr
2. Add a linter/pre-commit rule to ban bare print
  • ➕ Prevents offenders before tests run (faster feedback)
  • ➕ Can be consistently enforced across the repo
  • ➖ Requires introducing/maintaining lint tooling and config
  • ➖ Might need exemptions similar to the test anyway
3. Runtime guard: wrap/replace sys.stdout to detect writes
  • ➕ Catches dynamic writes from dependencies at runtime
  • ➕ Can include richer context (stack traces) in failures
  • ➖ More invasive; risk of breaking third-party libs expecting stdout
  • ➖ Harder to scope only to server/protocol execution paths

Recommendation: The chosen approach (remove the two offenders + add a static AST regression test) is the best tradeoff: it eliminates the immediate protocol corruption and adds a low-overhead, platform-independent guard. If stdout writes reoccur frequently, consider complementing this with a linter/pre-commit rule for earlier feedback.

Files changed (3) +105 / -3

Bug fix (2) +0 / -3
analytics.pyRemove stdout print from analytics tool tracking +0/-2

Remove stdout print from analytics tool tracking

• Drops a debug 'print()' that wrote tool execution summaries to stdout. Retains the same message via 'logger.info', keeping diagnostics on stderr and leaving stdout reserved for JSON-RPC framing.

src/windows_mcp/infrastructure/analytics.py

service.pyRemove per-word debug printing during UIA tree traversal +0/-1

Remove per-word debug printing during UIA tree traversal

• Eliminates a 'print(word, box)' inside word-bounding-box collection for editable/document/image controls. Prevents high-volume plain-text output from interleaving into the stdio JSON-RPC stream.

src/windows_mcp/tree/service.py

Tests (1) +105 / -0
test_no_stdout_writes.pyAdd AST-based test preventing bare stdout print() calls +105/-0

Add AST-based test preventing bare stdout print() calls

• Introduces a pytest that parses all 'src/windows_mcp/**/*.py' files and fails on non-exempt 'print()' calls that would write to stdout. Exempts prints gated by debug 'if' conditions and a specific upstream hotkey helper function by name.

tests/test_no_stdout_writes.py

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (3) 📜 Skill insights (0)

Context used
✅ Compliance rules (platform): 16 rules

Grey Divider


Remediation recommended

1. Single-quoted '<module>' literal 📘 Rule violation ✧ Quality
Description
A new string literal uses single quotes ('<module>') instead of the required double quotes. This
violates the repository-wide string literal quoting rule and may lead to inconsistent formatting
across files.
Code

tests/test_no_stdout_writes.py[101]

+            f"{path.relative_to(SRC_ROOT)}:{line} in {func or '<module>'} "
Relevance

●●● Strong

Single-quote literals in tests were previously flagged and fixed to match double-quote convention.

PR-#307
PR-#353

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 222799 requires double quotes for all string literals, but the added f-string uses
the single-quoted literal '<module>'.

Rule 222799: Enforce double quotes for all string literals
tests/test_no_stdout_writes.py[101-101]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A string literal uses single quotes (`'<module>'`) instead of double quotes.

## Issue Context
The codebase enforces double quotes for all string literals.

## Fix Focus Areas
- tests/test_no_stdout_writes.py[101-101]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Test methods lack type hints 📘 Rule violation ✧ Quality
Description
Newly added test methods do not fully annotate their signatures (missing return types and
unannotated parameters). This violates the requirement that all function signatures include explicit
type hints.
Code

tests/test_no_stdout_writes.py[R91-99]

+    def test_source_tree_was_found(self):
+        """Fail loudly rather than passing vacuously on a bad path."""
+        assert _source_files(), f"no Python sources under {SRC_ROOT}"
+
+    @pytest.mark.parametrize(
+        "path", _source_files(), ids=lambda p: str(p.relative_to(SRC_ROOT))
+    )
+    def test_no_bare_print(self, path: Path):
+        offenders = _find_stdout_prints(path)
Relevance

●●● Strong

Team previously accepted adding explicit type annotations (incl. -> None) to test functions.

PR-#307

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 222805 requires type hints for all function signatures, but
test_source_tree_was_found(self) and test_no_bare_print(self, path: Path) omit required
annotations (e.g., return type and other parameter annotations per the rule).

Rule 222805: Require type hints for all function signatures
tests/test_no_stdout_writes.py[91-99]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
New `def` methods in the added test file are missing required type annotations (e.g., return types like `-> None`, and parameter annotations as required by the rule).

## Issue Context
The compliance checklist requires explicit type hints for all function signatures, including methods.

## Fix Focus Areas
- tests/test_no_stdout_writes.py[91-99]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Missing docstrings in tests 📘 Rule violation ✧ Quality
Description
The public class TestNoStdoutWrites and its public method test_no_bare_print were added without
Google-style docstrings. This reduces readability and violates the required documentation standard
for public APIs in changed files.
Code

tests/test_no_stdout_writes.py[R90-99]

+class TestNoStdoutWrites:
+    def test_source_tree_was_found(self):
+        """Fail loudly rather than passing vacuously on a bad path."""
+        assert _source_files(), f"no Python sources under {SRC_ROOT}"
+
+    @pytest.mark.parametrize(
+        "path", _source_files(), ids=lambda p: str(p.relative_to(SRC_ROOT))
+    )
+    def test_no_bare_print(self, path: Path):
+        offenders = _find_stdout_prints(path)
Relevance

●● Moderate

Docstring enforcement exists for public APIs, but no prior evidence applying it to pytest test
classes/methods.

PR-#353
PR-#358

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 222802 requires Google-style docstrings on public classes/functions, but `class
TestNoStdoutWrites: has no class docstring and def test_no_bare_print(self, path: Path):` has no
docstring as its first statement.

Rule 222802: Require Google-style docstrings on public functions and classes
tests/test_no_stdout_writes.py[90-99]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Public class/method definitions in the newly added test file are missing required Google-style docstrings.

## Issue Context
The compliance checklist requires Google-style docstrings on public functions and classes in changed files.

## Fix Focus Areas
- tests/test_no_stdout_writes.py[90-99]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (2)
4. print(file=stdout) bypass 🐞 Bug ≡ Correctness
Description
The new stdout-print guard treats any print(..., file=...) as non-stdout, so `print(...,
file=sys.stdout) (or file=None`) will not be flagged even though it still writes to stdout and can
corrupt the stdio JSON-RPC stream. This weakens the regression test’s ability to prevent
reintroducing the original bug class.
Code

tests/test_no_stdout_writes.py[R64-76]

+        if (
+            isinstance(node, ast.Call)
+            and isinstance(node.func, ast.Name)
+            and node.func.id == "print"
+        ):
+            # `print(..., file=sys.stderr)` is not a stdout write.
+            writes_elsewhere = any(kw.arg == "file" for kw in node.keywords)
+            function = _enclosing_function(ancestors)
+            if (
+                not writes_elsewhere
+                and not _is_debug_gated(ancestors)
+                and function not in EXEMPT_FUNCTIONS
+            ):
Relevance

●● Moderate

No historical suggestions found about treating print(file=sys.stdout) as stdout in static guards.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The test’s detection sets writes_elsewhere true solely based on the presence of a file keyword,
not on whether the target is actually stdout; therefore print(..., file=sys.stdout) becomes
invisible to the scan.

tests/test_no_stdout_writes.py[64-77]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The static scan exempts *any* `print()` call that passes a `file=` keyword, but `file=sys.stdout` and `file=None` still write to stdout.

## Issue Context
This test is meant to prevent corruption of the stdio JSON-RPC protocol by stdout writes.

## Fix Focus Areas
- tests/test_no_stdout_writes.py[64-77]

## Implementation notes
- Change the logic to treat `file=` as safe only when it is clearly **not** stdout.
- At minimum, flag these as offenders:
 - `file=None`
 - `file=sys.stdout`
 - `file=sys.__stdout__`
- Keep allowing `file=sys.stderr` / `file=sys.__stderr__` and (optionally) other non-stdout streams.
- Add small unit-like cases (in this same test module) that build/parse a tiny snippet and assert the scanner catches `file=sys.stdout` / `file=None` and does not catch `file=sys.stderr`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Overbroad debug exemption 🐞 Bug ☼ Reliability
Description
The debug-gating exemption triggers if the enclosing if condition’s AST dump contains the
substring "debug", which also exempts prints in cases like if not debug: where the print would run
during normal (non-debug) operation. This can allow stdout prints to slip through the guard despite
being active in production paths.
Code

tests/test_no_stdout_writes.py[R37-44]

+def _is_debug_gated(ancestors: list[ast.AST]) -> bool:
+    """True if any enclosing `if` tests something named like a debug flag."""
+    for node in ancestors:
+        if isinstance(node, ast.If):
+            test_source = ast.dump(node.test).lower()
+            if "debug" in test_source:
+                return True
+    return False
Relevance

●● Moderate

No prior review evidence about debug-gating AST substring checks; similar searches returned
unrelated suggestions.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
_is_debug_gated() lowercases an AST dump and checks for the substring "debug"; this will return
True for many conditions containing that token regardless of whether the print is actually disabled
in normal operation.

tests/test_no_stdout_writes.py[37-44]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`_is_debug_gated()` uses a substring search over `ast.dump(node.test)`, which can mark non-debug (or inverted) conditions as "debug gated" and wrongly exempt stdout prints.

## Issue Context
This is a regression guard; false negatives reduce confidence that stdout stays clean for the JSON-RPC protocol.

## Fix Focus Areas
- tests/test_no_stdout_writes.py[37-44]

## Implementation notes
- Replace the substring heuristic with a structural check that only returns True for clearly-positive debug flags, e.g.:
 - `if debug:` / `if DEBUG:`
 - `if self.debug:` / `if ctx.debug:`
- Ensure it does **not** treat `if not debug:` or other negated forms as gated.
- Consider limiting matches to `ast.Name`/`ast.Attribute` identifiers containing `debug` rather than arbitrary dumped text.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

def test_no_bare_print(self, path: Path):
offenders = _find_stdout_prints(path)
assert offenders == [], "\n".join(
f"{path.relative_to(SRC_ROOT)}:{line} in {func or '<module>'} "

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Single-quoted '' literal 📘 Rule violation ✧ Quality

A new string literal uses single quotes ('<module>') instead of the required double quotes. This
violates the repository-wide string literal quoting rule and may lead to inconsistent formatting
across files.
Agent Prompt
## Issue description
A string literal uses single quotes (`'<module>'`) instead of double quotes.

## Issue Context
The codebase enforces double quotes for all string literals.

## Fix Focus Areas
- tests/test_no_stdout_writes.py[101-101]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +90 to +99
class TestNoStdoutWrites:
def test_source_tree_was_found(self):
"""Fail loudly rather than passing vacuously on a bad path."""
assert _source_files(), f"no Python sources under {SRC_ROOT}"

@pytest.mark.parametrize(
"path", _source_files(), ids=lambda p: str(p.relative_to(SRC_ROOT))
)
def test_no_bare_print(self, path: Path):
offenders = _find_stdout_prints(path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Missing docstrings in tests 📘 Rule violation ✧ Quality

The public class TestNoStdoutWrites and its public method test_no_bare_print were added without
Google-style docstrings. This reduces readability and violates the required documentation standard
for public APIs in changed files.
Agent Prompt
## Issue description
Public class/method definitions in the newly added test file are missing required Google-style docstrings.

## Issue Context
The compliance checklist requires Google-style docstrings on public functions and classes in changed files.

## Fix Focus Areas
- tests/test_no_stdout_writes.py[90-99]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +91 to +99
def test_source_tree_was_found(self):
"""Fail loudly rather than passing vacuously on a bad path."""
assert _source_files(), f"no Python sources under {SRC_ROOT}"

@pytest.mark.parametrize(
"path", _source_files(), ids=lambda p: str(p.relative_to(SRC_ROOT))
)
def test_no_bare_print(self, path: Path):
offenders = _find_stdout_prints(path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

3. Test methods lack type hints 📘 Rule violation ✧ Quality

Newly added test methods do not fully annotate their signatures (missing return types and
unannotated parameters). This violates the requirement that all function signatures include explicit
type hints.
Agent Prompt
## Issue description
New `def` methods in the added test file are missing required type annotations (e.g., return types like `-> None`, and parameter annotations as required by the rule).

## Issue Context
The compliance checklist requires explicit type hints for all function signatures, including methods.

## Fix Focus Areas
- tests/test_no_stdout_writes.py[91-99]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +64 to +76
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "print"
):
# `print(..., file=sys.stderr)` is not a stdout write.
writes_elsewhere = any(kw.arg == "file" for kw in node.keywords)
function = _enclosing_function(ancestors)
if (
not writes_elsewhere
and not _is_debug_gated(ancestors)
and function not in EXEMPT_FUNCTIONS
):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

4. Print(file=stdout) bypass 🐞 Bug ≡ Correctness

The new stdout-print guard treats any print(..., file=...) as non-stdout, so `print(...,
file=sys.stdout) (or file=None`) will not be flagged even though it still writes to stdout and can
corrupt the stdio JSON-RPC stream. This weakens the regression test’s ability to prevent
reintroducing the original bug class.
Agent Prompt
## Issue description
The static scan exempts *any* `print()` call that passes a `file=` keyword, but `file=sys.stdout` and `file=None` still write to stdout.

## Issue Context
This test is meant to prevent corruption of the stdio JSON-RPC protocol by stdout writes.

## Fix Focus Areas
- tests/test_no_stdout_writes.py[64-77]

## Implementation notes
- Change the logic to treat `file=` as safe only when it is clearly **not** stdout.
- At minimum, flag these as offenders:
  - `file=None`
  - `file=sys.stdout`
  - `file=sys.__stdout__`
- Keep allowing `file=sys.stderr` / `file=sys.__stderr__` and (optionally) other non-stdout streams.
- Add small unit-like cases (in this same test module) that build/parse a tiny snippet and assert the scanner catches `file=sys.stdout` / `file=None` and does not catch `file=sys.stderr`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +37 to +44
def _is_debug_gated(ancestors: list[ast.AST]) -> bool:
"""True if any enclosing `if` tests something named like a debug flag."""
for node in ancestors:
if isinstance(node, ast.If):
test_source = ast.dump(node.test).lower()
if "debug" in test_source:
return True
return False

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

5. Overbroad debug exemption 🐞 Bug ☼ Reliability

The debug-gating exemption triggers if the enclosing if condition’s AST dump contains the
substring "debug", which also exempts prints in cases like if not debug: where the print would run
during normal (non-debug) operation. This can allow stdout prints to slip through the guard despite
being active in production paths.
Agent Prompt
## Issue description
`_is_debug_gated()` uses a substring search over `ast.dump(node.test)`, which can mark non-debug (or inverted) conditions as "debug gated" and wrongly exempt stdout prints.

## Issue Context
This is a regression guard; false negatives reduce confidence that stdout stays clean for the JSON-RPC protocol.

## Fix Focus Areas
- tests/test_no_stdout_writes.py[37-44]

## Implementation notes
- Replace the substring heuristic with a structural check that only returns True for clearly-positive debug flags, e.g.:
  - `if debug:` / `if DEBUG:`
  - `if self.debug:` / `if ctx.debug:`
- Ensure it does **not** treat `if not debug:` or other negated forms as gated.
- Consider limiting matches to `ast.Name`/`ast.Attribute` identifiers containing `debug` rather than arbitrary dumped text.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@Jeomon
Jeomon merged commit c5dd285 into main Aug 1, 2026
2 checks passed
@Jeomon
Jeomon deleted the fix/stdout-writes-corrupt-stdio-protocol branch August 1, 2026 07:50
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.

1 participant