quietmcp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quietmcp-0.1.0/.github/workflows/ci.yml +46 -0
- quietmcp-0.1.0/.gitignore +21 -0
- quietmcp-0.1.0/CHANGELOG.md +43 -0
- quietmcp-0.1.0/CONTRIBUTING.md +39 -0
- quietmcp-0.1.0/LICENSE +21 -0
- quietmcp-0.1.0/PKG-INFO +159 -0
- quietmcp-0.1.0/README.md +135 -0
- quietmcp-0.1.0/RELEASE_READINESS.md +92 -0
- quietmcp-0.1.0/examples/quickstart.py +82 -0
- quietmcp-0.1.0/launch/README.md +40 -0
- quietmcp-0.1.0/launch/blog-post.md +149 -0
- quietmcp-0.1.0/launch/demo-walkthrough.py +118 -0
- quietmcp-0.1.0/launch/demo.svg +171 -0
- quietmcp-0.1.0/launch/make_demo_svg.py +135 -0
- quietmcp-0.1.0/launch/recording-guide.md +68 -0
- quietmcp-0.1.0/launch/social-copy.md +206 -0
- quietmcp-0.1.0/pyproject.toml +58 -0
- quietmcp-0.1.0/src/quietmcp/__init__.py +58 -0
- quietmcp-0.1.0/src/quietmcp/__main__.py +3 -0
- quietmcp-0.1.0/src/quietmcp/cli.py +50 -0
- quietmcp-0.1.0/src/quietmcp/guard.py +177 -0
- quietmcp-0.1.0/src/quietmcp/log.py +93 -0
- quietmcp-0.1.0/src/quietmcp/printing.py +31 -0
- quietmcp-0.1.0/src/quietmcp/py.typed +0 -0
- quietmcp-0.1.0/src/quietmcp/trace.py +183 -0
- quietmcp-0.1.0/tests/test_cli.py +76 -0
- quietmcp-0.1.0/tests/test_guard.py +139 -0
- quietmcp-0.1.0/tests/test_log.py +89 -0
- quietmcp-0.1.0/tests/test_printing.py +22 -0
- quietmcp-0.1.0/tests/test_trace.py +75 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
name: CI (quietmcp)
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
paths: ["projects/quietmcp/**"]
|
|
7
|
+
pull_request:
|
|
8
|
+
paths: ["projects/quietmcp/**"]
|
|
9
|
+
|
|
10
|
+
defaults:
|
|
11
|
+
run:
|
|
12
|
+
working-directory: projects/quietmcp
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
test:
|
|
16
|
+
runs-on: ${{ matrix.os }}
|
|
17
|
+
strategy:
|
|
18
|
+
fail-fast: false
|
|
19
|
+
matrix:
|
|
20
|
+
os: [ubuntu-latest]
|
|
21
|
+
python-version: ["3.9", "3.10", "3.11", "3.12"]
|
|
22
|
+
# quietmcp claims no OS restriction; verify the example/launch scripts run
|
|
23
|
+
# under Windows' default cp1252 console (where naive Unicode prints crash).
|
|
24
|
+
include:
|
|
25
|
+
- os: windows-latest
|
|
26
|
+
python-version: "3.12"
|
|
27
|
+
steps:
|
|
28
|
+
- uses: actions/checkout@v4
|
|
29
|
+
- uses: actions/setup-python@v5
|
|
30
|
+
with:
|
|
31
|
+
python-version: ${{ matrix.python-version }}
|
|
32
|
+
- name: Install
|
|
33
|
+
run: pip install -e ".[dev]"
|
|
34
|
+
- name: Lint
|
|
35
|
+
run: ruff check .
|
|
36
|
+
- name: Run tests
|
|
37
|
+
run: pytest
|
|
38
|
+
- name: Run the example
|
|
39
|
+
run: python examples/quickstart.py
|
|
40
|
+
- name: Smoke-test launch scripts
|
|
41
|
+
env:
|
|
42
|
+
NO_PAUSE: "1"
|
|
43
|
+
run: |
|
|
44
|
+
python launch/demo-walkthrough.py
|
|
45
|
+
pip install rich # only needed to render the demo image, not to use quietmcp
|
|
46
|
+
python launch/make_demo_svg.py
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.venv/
|
|
9
|
+
venv/
|
|
10
|
+
|
|
11
|
+
# Test / tooling
|
|
12
|
+
.pytest_cache/
|
|
13
|
+
.mypy_cache/
|
|
14
|
+
.ruff_cache/
|
|
15
|
+
.coverage
|
|
16
|
+
htmlcov/
|
|
17
|
+
|
|
18
|
+
# OS / editor
|
|
19
|
+
.DS_Store
|
|
20
|
+
.idea/
|
|
21
|
+
.vscode/
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format is based on
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/), and this project adheres to
|
|
5
|
+
[Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-06-13
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- `safe_print(...)` — a `print()` drop-in that writes to **stderr**, so it can't
|
|
11
|
+
corrupt an MCP stdio server's JSON-RPC stream.
|
|
12
|
+
- `get_logger(name, *, level, stream, file, json, trace)` — a logger that writes to
|
|
13
|
+
stderr/a file (never stdout), sets `propagate=False` (so records can't bubble to a
|
|
14
|
+
root handler aimed at stdout), is idempotent, and **refuses** `stream=sys.stdout`.
|
|
15
|
+
- `install()` / `uninstall()` / `protect_stdout()` — guard `sys.stdout` so stray
|
|
16
|
+
**text** writes (yours or a dependency's) are redirected to stderr (`"redirect"`),
|
|
17
|
+
dropped (`"drop"`), or raise `StdoutPollutionError` (`"strict"`). Returns the real
|
|
18
|
+
stdout for protocol writes; leaves `sys.stdout.buffer` passing through to the real
|
|
19
|
+
stream, so the MCP SDK's JSON-RPC bytes are untouched.
|
|
20
|
+
- Trace capture + local viewer: `TraceHandler` tees structured records to a JSONL
|
|
21
|
+
file; `read_trace` / `render_trace` (text) / `render_trace_html` (a self-contained
|
|
22
|
+
page) read them back. CLI: `python -m quietmcp view trace.jsonl [--level L] [--html PATH]`.
|
|
23
|
+
- Zero-dependency, fully typed, with a runnable `examples/quickstart.py` and a full
|
|
24
|
+
offline test suite.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
- `python -m quietmcp view` fails cleanly with a short stderr message and exit code
|
|
28
|
+
2 when the trace file is missing or isn't valid JSONL, instead of dumping a raw
|
|
29
|
+
traceback. (Errors go to stderr — never stdout.)
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
- `read_trace` reads with `utf-8-sig`, so a trace written as UTF-8-with-BOM (common
|
|
33
|
+
from Windows editors/tooling) loads instead of failing with an "Unexpected UTF-8
|
|
34
|
+
BOM" error. BOM-less UTF-8 is unaffected.
|
|
35
|
+
- The `examples/quickstart.py` and `launch/demo-walkthrough.py` demos reconfigure
|
|
36
|
+
their own output to UTF-8, so they no longer crash on Windows' default cp1252
|
|
37
|
+
console when stdout is piped (the protocol stream the library guards is untouched).
|
|
38
|
+
|
|
39
|
+
### Notes
|
|
40
|
+
- Honest scope: quietmcp has no dependency on any MCP SDK (it just keeps stdout
|
|
41
|
+
clean for the one you use), and it guards Python-level writes — not raw OS file
|
|
42
|
+
descriptor 1 (a subprocess or C extension writing to fd 1 needs OS-level
|
|
43
|
+
redirection).
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Contributing to quietmcp
|
|
2
|
+
|
|
3
|
+
Thanks for your interest! quietmcp aims to stay **tiny, dependency-free, and
|
|
4
|
+
laser-focused** on one thing: never letting non-protocol output reach an MCP
|
|
5
|
+
server's stdout. Contributions that keep it that way are welcome.
|
|
6
|
+
|
|
7
|
+
## Dev setup
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
python -m venv .venv && source .venv/bin/activate
|
|
11
|
+
pip install -e ".[dev]"
|
|
12
|
+
pytest
|
|
13
|
+
ruff check .
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The whole test suite runs **offline** — no network, no MCP server, no subprocess.
|
|
17
|
+
Stream behaviour is tested with in-memory `StringIO`s and pytest's `capsys`.
|
|
18
|
+
|
|
19
|
+
## Guidelines
|
|
20
|
+
|
|
21
|
+
- **Zero dependencies.** quietmcp must not depend on any MCP SDK or third-party
|
|
22
|
+
package; it should compose with whatever the user already has.
|
|
23
|
+
- **Never write to stdout.** Every code path either targets stderr/a file, or
|
|
24
|
+
passes protocol bytes through the real stdout deliberately. New features must
|
|
25
|
+
preserve that invariant (and ideally test it with `capsys`).
|
|
26
|
+
- **Fail loud on misuse.** Pointing a logger at stdout, or a stray write in
|
|
27
|
+
`strict` mode, should raise a clear error — that's the bug class we exist to catch.
|
|
28
|
+
- **Every change ships with tests and docs.**
|
|
29
|
+
|
|
30
|
+
## Ideas / good first issues
|
|
31
|
+
|
|
32
|
+
- An OS-level fd-redirection helper (`os.dup2`) for stdout written by subprocesses
|
|
33
|
+
or C extensions, as an opt-in beyond the Python-level guard.
|
|
34
|
+
- Async-friendly helpers / an example wiring quietmcp into the official `mcp` SDK's
|
|
35
|
+
`stdio_server`.
|
|
36
|
+
- A `--follow` (tail) mode for `python -m quietmcp view`.
|
|
37
|
+
- Bridging to MCP's own `notifications/message` logging.
|
|
38
|
+
|
|
39
|
+
Open an issue to discuss before large changes. Be kind; assume good intent.
|
quietmcp-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jenil Soni
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
quietmcp-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: quietmcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Keep your MCP server quiet on stdout — safe print/logging and a stdout guard so debug output can't corrupt the JSON-RPC stream.
|
|
5
|
+
Project-URL: Homepage, https://github.com/thejenilsoni/quietmcp
|
|
6
|
+
Project-URL: Issues, https://github.com/thejenilsoni/quietmcp/issues
|
|
7
|
+
Author: Jenil Soni
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agents,debugging,json-rpc,llm,logging,mcp,model-context-protocol,stdio
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Classifier: Topic :: System :: Logging
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
22
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# quietmcp
|
|
26
|
+
|
|
27
|
+
> Keep your MCP server quiet on stdout — `print()` and log freely without
|
|
28
|
+
> corrupting the JSON-RPC stream.
|
|
29
|
+
|
|
30
|
+

|
|
31
|
+

|
|
32
|
+
|
|
33
|
+
On an MCP **stdio** server, `stdout` *is* the protocol — every byte is part of the
|
|
34
|
+
JSON-RPC stream the client parses. So the most natural thing in the world, a
|
|
35
|
+
`print("got here")`, silently corrupts that stream and the client breaks with a
|
|
36
|
+
baffling parse error. Worse, a third-party library you don't control can `print` a
|
|
37
|
+
progress bar and take your server down with it.
|
|
38
|
+
|
|
39
|
+
The fix is mundane — send everything that isn't protocol to **stderr** — but easy
|
|
40
|
+
to get wrong and easy for a dependency to undo. **quietmcp** makes it effortless:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import quietmcp
|
|
44
|
+
|
|
45
|
+
real_stdout = quietmcp.install() # stray writes to stdout now go to stderr
|
|
46
|
+
log = quietmcp.get_logger("my-server") # logs to stderr, never stdout
|
|
47
|
+
log.info("server starting")
|
|
48
|
+
quietmcp.safe_print("debug: tool called") # print(), but safe
|
|
49
|
+
|
|
50
|
+
# hand the REAL stdout to your MCP transport for JSON-RPC:
|
|
51
|
+
run_stdio_server(stdout=real_stdout)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Now nothing — your code or your dependencies — can poison the protocol with a stray
|
|
55
|
+
text write.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Install
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pip install quietmcp
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Zero dependencies, pure standard library.
|
|
66
|
+
|
|
67
|
+
## The three pieces
|
|
68
|
+
|
|
69
|
+
### 1. `safe_print` — `print()` that goes to stderr
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from quietmcp import safe_print
|
|
73
|
+
safe_print("about to call tool", tool_name) # identical to print(), but on stderr
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### 2. `get_logger` — a logger that can't reach stdout
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
log = quietmcp.get_logger("my-server", level=logging.DEBUG,
|
|
80
|
+
file="server.log", json=False, trace="trace.jsonl")
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
It logs to **stderr** (or a file), and — critically — sets `propagate=False` so your
|
|
84
|
+
records never bubble up to a root logger whose handler might be pointed at stdout.
|
|
85
|
+
It's idempotent, so calling it again with the same name won't stack duplicate
|
|
86
|
+
handlers. Pass `stream=sys.stdout` and it refuses with a clear error: that's the bug
|
|
87
|
+
it exists to prevent.
|
|
88
|
+
|
|
89
|
+
### 3. `install()` — guard stdout against *everything*
|
|
90
|
+
|
|
91
|
+
The piece that saves you from libraries you don't control. `install()` replaces
|
|
92
|
+
`sys.stdout` with a guard and returns the real stream:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
real_stdout = quietmcp.install(mode="redirect") # default: stray writes -> stderr
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
| mode | a stray `print()` to stdout… |
|
|
99
|
+
|------|------------------------------|
|
|
100
|
+
| `"redirect"` (default) | is written to stderr instead (you still see it) |
|
|
101
|
+
| `"drop"` | is silently discarded |
|
|
102
|
+
| `"strict"` | raises `StdoutPollutionError` — great for catching the offender in tests/CI |
|
|
103
|
+
|
|
104
|
+
The guard only intercepts **text** writes. The binary layer (`sys.stdout.buffer`) —
|
|
105
|
+
which the Python MCP SDK uses to emit JSON-RPC — passes straight through to the real
|
|
106
|
+
stdout. So protocol output works untouched while `print()`/logging is contained.
|
|
107
|
+
That's exactly the split you want, and it's why `install()` + the MCP SDK just work
|
|
108
|
+
together.
|
|
109
|
+
|
|
110
|
+
Use it as a context manager when you'd rather scope it (e.g. in tests):
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from quietmcp import protect_stdout, StdoutPollutionError
|
|
114
|
+
|
|
115
|
+
with protect_stdout("strict") as real_stdout:
|
|
116
|
+
run_one_request() # any stray stdout write in here raises
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
> Note: this guards Python-level writes (`print`, `logging`, `sys.stdout.write`). A
|
|
120
|
+
> subprocess or a C extension writing to fd 1 directly is out of scope — those need
|
|
121
|
+
> OS-level redirection.
|
|
122
|
+
|
|
123
|
+
## Capture and view a trace
|
|
124
|
+
|
|
125
|
+
Pass `trace="…"` to `get_logger` (or attach a `TraceHandler`) to tee structured
|
|
126
|
+
records to a JSONL file, then read it back later — your own logs, kept, instead of
|
|
127
|
+
scrolling off stderr:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
log = quietmcp.get_logger("my-server", trace="trace.jsonl")
|
|
131
|
+
log.info("tool call", extra={"tool": "search", "ms": 12})
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
python -m quietmcp view trace.jsonl # readable table in the terminal
|
|
136
|
+
python -m quietmcp view trace.jsonl --level WARNING # filter to WARNING and up
|
|
137
|
+
python -m quietmcp view trace.jsonl --html trace.html # a self-contained browsable page
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Programmatically: `read_trace`, `render_trace`, and `render_trace_html`.
|
|
141
|
+
|
|
142
|
+
## What it does **not** do
|
|
143
|
+
|
|
144
|
+
- **It doesn't speak MCP.** It has no dependency on any MCP SDK and adds no protocol
|
|
145
|
+
features — it just keeps your stdout clean so the SDK you already use keeps working.
|
|
146
|
+
- **It doesn't redirect OS file descriptors.** Subprocesses/C libraries writing to
|
|
147
|
+
fd 1 directly aren't intercepted (that needs `os.dup2`-level redirection).
|
|
148
|
+
- **It's not an observability platform.** The trace viewer is a local convenience,
|
|
149
|
+
not a hosted dashboard.
|
|
150
|
+
|
|
151
|
+
## Part of an agent-reliability suite
|
|
152
|
+
|
|
153
|
+
quietmcp is the first of a small set of framework-agnostic tools for shipping
|
|
154
|
+
agents and agent infrastructure you can trust. More are on the way.
|
|
155
|
+
|
|
156
|
+
## Contributing
|
|
157
|
+
|
|
158
|
+
See `CONTRIBUTING.md` in the source distribution. The bar: zero dependencies, never write to
|
|
159
|
+
stdout, and ship tests + docs with every change. MIT licensed.
|
quietmcp-0.1.0/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# quietmcp
|
|
2
|
+
|
|
3
|
+
> Keep your MCP server quiet on stdout — `print()` and log freely without
|
|
4
|
+
> corrupting the JSON-RPC stream.
|
|
5
|
+
|
|
6
|
+

|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
On an MCP **stdio** server, `stdout` *is* the protocol — every byte is part of the
|
|
10
|
+
JSON-RPC stream the client parses. So the most natural thing in the world, a
|
|
11
|
+
`print("got here")`, silently corrupts that stream and the client breaks with a
|
|
12
|
+
baffling parse error. Worse, a third-party library you don't control can `print` a
|
|
13
|
+
progress bar and take your server down with it.
|
|
14
|
+
|
|
15
|
+
The fix is mundane — send everything that isn't protocol to **stderr** — but easy
|
|
16
|
+
to get wrong and easy for a dependency to undo. **quietmcp** makes it effortless:
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
import quietmcp
|
|
20
|
+
|
|
21
|
+
real_stdout = quietmcp.install() # stray writes to stdout now go to stderr
|
|
22
|
+
log = quietmcp.get_logger("my-server") # logs to stderr, never stdout
|
|
23
|
+
log.info("server starting")
|
|
24
|
+
quietmcp.safe_print("debug: tool called") # print(), but safe
|
|
25
|
+
|
|
26
|
+
# hand the REAL stdout to your MCP transport for JSON-RPC:
|
|
27
|
+
run_stdio_server(stdout=real_stdout)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Now nothing — your code or your dependencies — can poison the protocol with a stray
|
|
31
|
+
text write.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install quietmcp
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Zero dependencies, pure standard library.
|
|
42
|
+
|
|
43
|
+
## The three pieces
|
|
44
|
+
|
|
45
|
+
### 1. `safe_print` — `print()` that goes to stderr
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from quietmcp import safe_print
|
|
49
|
+
safe_print("about to call tool", tool_name) # identical to print(), but on stderr
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### 2. `get_logger` — a logger that can't reach stdout
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
log = quietmcp.get_logger("my-server", level=logging.DEBUG,
|
|
56
|
+
file="server.log", json=False, trace="trace.jsonl")
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
It logs to **stderr** (or a file), and — critically — sets `propagate=False` so your
|
|
60
|
+
records never bubble up to a root logger whose handler might be pointed at stdout.
|
|
61
|
+
It's idempotent, so calling it again with the same name won't stack duplicate
|
|
62
|
+
handlers. Pass `stream=sys.stdout` and it refuses with a clear error: that's the bug
|
|
63
|
+
it exists to prevent.
|
|
64
|
+
|
|
65
|
+
### 3. `install()` — guard stdout against *everything*
|
|
66
|
+
|
|
67
|
+
The piece that saves you from libraries you don't control. `install()` replaces
|
|
68
|
+
`sys.stdout` with a guard and returns the real stream:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
real_stdout = quietmcp.install(mode="redirect") # default: stray writes -> stderr
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
| mode | a stray `print()` to stdout… |
|
|
75
|
+
|------|------------------------------|
|
|
76
|
+
| `"redirect"` (default) | is written to stderr instead (you still see it) |
|
|
77
|
+
| `"drop"` | is silently discarded |
|
|
78
|
+
| `"strict"` | raises `StdoutPollutionError` — great for catching the offender in tests/CI |
|
|
79
|
+
|
|
80
|
+
The guard only intercepts **text** writes. The binary layer (`sys.stdout.buffer`) —
|
|
81
|
+
which the Python MCP SDK uses to emit JSON-RPC — passes straight through to the real
|
|
82
|
+
stdout. So protocol output works untouched while `print()`/logging is contained.
|
|
83
|
+
That's exactly the split you want, and it's why `install()` + the MCP SDK just work
|
|
84
|
+
together.
|
|
85
|
+
|
|
86
|
+
Use it as a context manager when you'd rather scope it (e.g. in tests):
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from quietmcp import protect_stdout, StdoutPollutionError
|
|
90
|
+
|
|
91
|
+
with protect_stdout("strict") as real_stdout:
|
|
92
|
+
run_one_request() # any stray stdout write in here raises
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
> Note: this guards Python-level writes (`print`, `logging`, `sys.stdout.write`). A
|
|
96
|
+
> subprocess or a C extension writing to fd 1 directly is out of scope — those need
|
|
97
|
+
> OS-level redirection.
|
|
98
|
+
|
|
99
|
+
## Capture and view a trace
|
|
100
|
+
|
|
101
|
+
Pass `trace="…"` to `get_logger` (or attach a `TraceHandler`) to tee structured
|
|
102
|
+
records to a JSONL file, then read it back later — your own logs, kept, instead of
|
|
103
|
+
scrolling off stderr:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
log = quietmcp.get_logger("my-server", trace="trace.jsonl")
|
|
107
|
+
log.info("tool call", extra={"tool": "search", "ms": 12})
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
python -m quietmcp view trace.jsonl # readable table in the terminal
|
|
112
|
+
python -m quietmcp view trace.jsonl --level WARNING # filter to WARNING and up
|
|
113
|
+
python -m quietmcp view trace.jsonl --html trace.html # a self-contained browsable page
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Programmatically: `read_trace`, `render_trace`, and `render_trace_html`.
|
|
117
|
+
|
|
118
|
+
## What it does **not** do
|
|
119
|
+
|
|
120
|
+
- **It doesn't speak MCP.** It has no dependency on any MCP SDK and adds no protocol
|
|
121
|
+
features — it just keeps your stdout clean so the SDK you already use keeps working.
|
|
122
|
+
- **It doesn't redirect OS file descriptors.** Subprocesses/C libraries writing to
|
|
123
|
+
fd 1 directly aren't intercepted (that needs `os.dup2`-level redirection).
|
|
124
|
+
- **It's not an observability platform.** The trace viewer is a local convenience,
|
|
125
|
+
not a hosted dashboard.
|
|
126
|
+
|
|
127
|
+
## Part of an agent-reliability suite
|
|
128
|
+
|
|
129
|
+
quietmcp is the first of a small set of framework-agnostic tools for shipping
|
|
130
|
+
agents and agent infrastructure you can trust. More are on the way.
|
|
131
|
+
|
|
132
|
+
## Contributing
|
|
133
|
+
|
|
134
|
+
See `CONTRIBUTING.md` in the source distribution. The bar: zero dependencies, never write to
|
|
135
|
+
stdout, and ship tests + docs with every change. MIT licensed.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# quietmcp — Release Readiness (v0.1.0)
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-06-13 · **Verdict: GO (publish-ready)** — pending only Jenil's
|
|
4
|
+
go-ahead + PyPI credentials.
|
|
5
|
+
|
|
6
|
+
This is a fresh re-assessment run on **Windows 11 / Python 3.13** (the prior pass,
|
|
7
|
+
2026-06-03, ran in the Linux web container on Python 3.11). The cross-platform run
|
|
8
|
+
surfaced three real Windows footguns, all now fixed in place. quietmcp itself —
|
|
9
|
+
the library — was already solid; the issues were in the demo/launch scripts and one
|
|
10
|
+
file-read encoding.
|
|
11
|
+
|
|
12
|
+
quietmcp keeps an MCP **stdio** server's stdout clean: a `print()`-safe helper, a
|
|
13
|
+
stdout-refusing logger, a guard that contains stray text writes (yours or a
|
|
14
|
+
dependency's), and a tiny JSONL trace viewer. Zero-dependency core, fully typed.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Verification results (this run, Windows / Py3.13)
|
|
19
|
+
|
|
20
|
+
| Check | Result |
|
|
21
|
+
|-------|--------|
|
|
22
|
+
| `pip install -e ".[dev]"` | ✅ clean (Python 3.13.13, Windows) |
|
|
23
|
+
| `ruff check .` | ✅ All checks passed |
|
|
24
|
+
| `python -m pytest` | ✅ **36 passed** (was 35; +1 BOM regression test) |
|
|
25
|
+
| `python -m build` | ✅ builds `sdist` + `wheel` in isolated env |
|
|
26
|
+
| Zero-dep import | ✅ `import quietmcp` with no extras; `__version__ == "0.1.0"`, 15 public exports |
|
|
27
|
+
| `py.typed` ships in wheel | ✅ present at `quietmcp/py.typed` |
|
|
28
|
+
| Console-script entry point | ✅ `quietmcp = quietmcp.cli:main` in wheel `entry_points.txt`; `quietmcp --help` works |
|
|
29
|
+
| `examples/quickstart.py` | ✅ runs (incl. **piped stdout** on Windows — see fix #2) |
|
|
30
|
+
| `launch/demo-walkthrough.py` | ✅ runs (incl. piped stdout on Windows — see fix #2) |
|
|
31
|
+
| CLI `view` / `--level` / `--html` / `python -m quietmcp` | ✅ all paths work end-to-end |
|
|
32
|
+
| CLI error paths (missing file, bad JSONL) | ✅ one-line message **to stderr**, exit code 2 |
|
|
33
|
+
| Name availability | ✅ free as of the prior check (**re-verify at publish time** — see below) |
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Fixes applied this session (Windows hardening, in place)
|
|
38
|
+
|
|
39
|
+
1. **`read_trace` rejected UTF-8-with-BOM trace files.** It opened with `utf-8`,
|
|
40
|
+
which raises "Unexpected UTF-8 BOM" on a BOM'd file (common from Windows
|
|
41
|
+
editors/tooling — e.g. PowerShell `Out-File -Encoding utf8`). quietmcp's *own*
|
|
42
|
+
`TraceHandler` writes BOM-less UTF-8, so the self round-trip was fine, but a
|
|
43
|
+
trace from any BOM-writing tool was unreadable. **Fix:** read with `utf-8-sig`
|
|
44
|
+
(transparently strips a BOM; identical for BOM-less files). Added a regression
|
|
45
|
+
test (`test_read_trace_tolerates_utf8_bom`). `src/quietmcp/trace.py`.
|
|
46
|
+
|
|
47
|
+
2. **The demo scripts crashed on Windows' cp1252 console.** `examples/quickstart.py`
|
|
48
|
+
and `launch/demo-walkthrough.py` print a `✓` (U+2713); when stdout is a pipe on
|
|
49
|
+
Windows (CI logs, recordings, this harness), Python falls back to cp1252 and
|
|
50
|
+
`✓` raises `UnicodeEncodeError` — *after* printing "it worked", so a Windows dev's
|
|
51
|
+
first run ends in a traceback. **Fix:** each demo best-effort `reconfigure`s its
|
|
52
|
+
own output to UTF-8 at startup (the protocol stream the library guards is never
|
|
53
|
+
touched). Root-cause fix, so em-dashes/`✓` render everywhere.
|
|
54
|
+
|
|
55
|
+
3. **CI never tested Windows.** The matrix was `ubuntu-latest` only, so #1 and #2
|
|
56
|
+
could never be caught despite `requires-python` declaring no OS restriction.
|
|
57
|
+
**Fix:** added a `windows-latest` lane (Py3.12) that runs the example + launch
|
|
58
|
+
scripts under the default console. `.github/workflows/ci.yml`.
|
|
59
|
+
|
|
60
|
+
All three are committed as code/test/CI/CHANGELOG changes (the "perfect commit"
|
|
61
|
+
discipline). Tests 35 → **36**, still ruff-clean, build still green.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Punch list (non-blocking, post-launch polish)
|
|
66
|
+
|
|
67
|
+
- **P3 — `install()` mode is sticky.** A second `install("strict")` after
|
|
68
|
+
`install("redirect")` silently returns the first guard and ignores the new mode
|
|
69
|
+
(idempotent by design, for "install once at startup"). Consider a docstring note
|
|
70
|
+
or a `warnings.warn` when `mode` differs from the live guard.
|
|
71
|
+
- **P3 — `get_logger(file=..., stream=...)`** attaches both handlers; with only
|
|
72
|
+
`file=` it logs to the file alone (no stderr echo). Intended — worth one README
|
|
73
|
+
sentence so users aren't surprised `file=` silences stderr.
|
|
74
|
+
- **P3 — `render_trace` to a piped console** can still hit cp1252 if a *user's* log
|
|
75
|
+
message contains non-ASCII (general Python-on-Windows behaviour, not quietmcp's
|
|
76
|
+
output). Not worth a code change; could be a one-line FAQ note.
|
|
77
|
+
|
|
78
|
+
None of these block a v0.1.0 launch.
|
|
79
|
+
|
|
80
|
+
## Pre-publish checklist (when Jenil gives the go-ahead)
|
|
81
|
+
|
|
82
|
+
1. **Re-verify the name** on PyPI + `github.com/thejenilsoni/quietmcp` (was free at
|
|
83
|
+
last check; confirm again immediately before upload).
|
|
84
|
+
2. Decide whether the *Unreleased* entries ship as **v0.1.0** (fold them in) or as
|
|
85
|
+
**v0.1.1** — they're currently staged under *Unreleased*.
|
|
86
|
+
3. Extract `projects/quietmcp/` to its own repo; rewrite sibling links
|
|
87
|
+
(`../agentvcr/` etc. in README) to absolute `github.com/thejenilsoni/...` URLs.
|
|
88
|
+
4. `python -m build` → `twine check dist/*` → `twine upload` (twine not installed
|
|
89
|
+
here; needs a PyPI token — **do not upload without Jenil's go-ahead**).
|
|
90
|
+
5. Record the killer demo (noisy dep `print()`s mid-request, JSON-RPC stays clean)
|
|
91
|
+
per `launch/recording-guide.md`. Consider co-launching with `mcplens` as an
|
|
92
|
+
"MCP server toolkit."
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Show that debug output can't corrupt the MCP protocol stream — offline.
|
|
2
|
+
|
|
3
|
+
Run me: python examples/quickstart.py
|
|
4
|
+
|
|
5
|
+
We simulate one MCP request handler. A rude third-party library `print()`s to
|
|
6
|
+
stdout (which on a real server is the JSON-RPC channel), and we also log and
|
|
7
|
+
safe_print debug info. With quietmcp installed, only the actual protocol response
|
|
8
|
+
reaches stdout; everything else is safely redirected to stderr and captured to a
|
|
9
|
+
trace we then render.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import io
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import quietmcp
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def noisy_library_call() -> dict:
|
|
24
|
+
# A dependency that rudely writes to stdout — the classic MCP stream-corrupter.
|
|
25
|
+
print("LIB: download progress 50%")
|
|
26
|
+
print("LIB: download progress 100%")
|
|
27
|
+
return {"temperature_c": 21}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def handle_request(real_stdout, log) -> None:
|
|
31
|
+
log.info("handling tools/call", extra={"tool": "get_weather"})
|
|
32
|
+
quietmcp.safe_print("DEBUG: validating arguments") # muscle-memory print(), now safe
|
|
33
|
+
result = noisy_library_call()
|
|
34
|
+
log.info("tool succeeded", extra={"result": result})
|
|
35
|
+
# The ONLY thing that should ever hit stdout: the JSON-RPC response.
|
|
36
|
+
response = {"jsonrpc": "2.0", "id": 1, "result": result}
|
|
37
|
+
real_stdout.write(json.dumps(response) + "\n")
|
|
38
|
+
real_stdout.flush()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main() -> None:
|
|
42
|
+
# Make the demo's own narration readable on any console (e.g. Windows cp1252),
|
|
43
|
+
# even when stdout is a pipe. Best-effort; this is the display channel, not the
|
|
44
|
+
# protocol stream the library protects.
|
|
45
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
46
|
+
try:
|
|
47
|
+
_stream.reconfigure(encoding="utf-8") # type: ignore[union-attr]
|
|
48
|
+
except (AttributeError, ValueError):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
protocol = io.StringIO() # stands in for the real stdout (the MCP channel)
|
|
52
|
+
stderr_cap = io.StringIO() # stands in for stderr
|
|
53
|
+
trace_path = Path(tempfile.gettempdir()) / "quietmcp-demo.jsonl"
|
|
54
|
+
trace_path.unlink(missing_ok=True)
|
|
55
|
+
|
|
56
|
+
saved_out, saved_err = sys.stdout, sys.stderr
|
|
57
|
+
sys.stdout, sys.stderr = protocol, stderr_cap
|
|
58
|
+
try:
|
|
59
|
+
real_stdout = quietmcp.install(to=stderr_cap) # guard stdout; stray writes -> stderr
|
|
60
|
+
log = quietmcp.get_logger("demo", stream=stderr_cap, trace=str(trace_path))
|
|
61
|
+
handle_request(real_stdout, log)
|
|
62
|
+
finally:
|
|
63
|
+
quietmcp.uninstall()
|
|
64
|
+
sys.stdout, sys.stderr = saved_out, saved_err
|
|
65
|
+
|
|
66
|
+
print("=== stdout — the JSON-RPC channel (must be protocol ONLY) ===")
|
|
67
|
+
print(protocol.getvalue().strip())
|
|
68
|
+
print("\n=== stderr — debug + the noisy library, safely redirected ===")
|
|
69
|
+
print(stderr_cap.getvalue().strip())
|
|
70
|
+
|
|
71
|
+
# Prove the protocol stream was never polluted.
|
|
72
|
+
assert "LIB:" not in protocol.getvalue()
|
|
73
|
+
assert "DEBUG:" not in protocol.getvalue()
|
|
74
|
+
assert json.loads(protocol.getvalue())["result"] == {"temperature_c": 21}
|
|
75
|
+
|
|
76
|
+
print("\n=== captured trace (python -m quietmcp view ...) ===")
|
|
77
|
+
print(quietmcp.render_trace(quietmcp.read_trace(trace_path)))
|
|
78
|
+
print("\nstdout stayed clean — the protocol survived a noisy dependency. ✓")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
main()
|