Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions src/windows_mcp/infrastructure/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,6 @@ async def track_tool(self, tool_name: str, result: Dict[str, Any]) -> None:

duration = result.get("duration_ms", 0)
success_mark = "SUCCESS" if result.get("success") else "FAILED"
# 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)")

async def track_error(self, error: Exception, context: Dict[str, Any]) -> None:
Expand Down
1 change: 0 additions & 1 deletion src/windows_mcp/tree/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -568,7 +568,6 @@ def tree_traversal(self, node: Control, window_bounding_box:Rect, window_name:st
for word,boxes in words:
for box in boxes:
word_elements.append((word,box))
print(word,box)
except Exception:
pass

Expand Down
105 changes: 105 additions & 0 deletions tests/test_no_stdout_writes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""Guard against bare `print()` in the server's source.

The default transport is stdio (`__main__.py`), where stdout *is* the
JSON-RPC channel -- as the codebase already notes elsewhere: "Written to
stderr because stdout carries the stdio protocol". Anything printed to
stdout is interleaved into that stream and corrupts the framing the client
is parsing.

This is a static scan rather than a runtime check, so it needs no Windows
imports and runs anywhere.

Two narrow exemptions, both deliberate:

* prints lexically inside an `if ...debug...:` block -- opt-in, off by
default, and never reached in normal operation;
* `RunByHotKey`, a standalone hotkey-runner utility carried over from the
upstream `uiautomation` library that the MCP server never invokes.

Anything else should use `logger`, which is already configured to write to
stderr.
"""

import ast
from pathlib import Path

import pytest

SRC_ROOT = Path(__file__).resolve().parent.parent / "src" / "windows_mcp"

# Enclosing functions exempted wholesale. Keyed by name rather than line
# number so the allowlist survives edits elsewhere in the file.
EXEMPT_FUNCTIONS = {
"threadFunc", # nested inside RunByHotKey; upstream uiautomation utility
}


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
Comment on lines +37 to +44

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



def _enclosing_function(ancestors: list[ast.AST]) -> str | None:
for node in reversed(ancestors):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
return node.name
return None


def _find_stdout_prints(path: Path) -> list[tuple[int, str | None]]:
"""Return (lineno, enclosing function) for each non-exempt print call.

Only `ast.Call` nodes count, so prints appearing inside docstring
examples are excluded for free -- those are strings, not calls.
"""
tree = ast.parse(path.read_text(encoding="utf-8"))
offenders: list[tuple[int, str | None]] = []

def walk(node: ast.AST, ancestors: list[ast.AST]) -> None:
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
):
Comment on lines +64 to +76

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

offenders.append((node.lineno, function))

for child in ast.iter_child_nodes(node):
walk(child, ancestors + [node])

walk(tree, [])
return offenders


def _source_files() -> list[Path]:
return sorted(SRC_ROOT.rglob("*.py"))


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)
Comment on lines +90 to +99

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

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

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

"writes to stdout, which carries the stdio JSON-RPC protocol; "
"use logger instead"
for line, func in offenders
)
Loading