fix(mcp): redact error text crossing the trust boundary — a handler exception leaked credentials - #35
Merged
Merged
Conversation
…xception leaked credentials
`mcp_server.py` was the lowest-covered core module at 80.3%. The uncovered lines were not
incidental: they were the two ERROR paths, and both handed an exception's text to the
untrusted MCP peer verbatim.
_invoke_tool json.dumps({"error": ..., "message": str(exc)})
_discover_tools description = f"[LOAD ERROR: {exc}]" <- SERVED by list_tools
Reproduced. A handler raising
RuntimeError("connect failed: postgresql://svc:SUPERSECRET_PW@db.internal:5432/soc
(token=ABSK_LIVE_deadbeef)")
delivered the password, the token and the internal hostname to the peer:
{"error": "RuntimeError", "message": "connect failed: postgresql://svc:SUPERSECRET_PW@..."}
The second path is worse for being quieter — an import-time failure becomes a tool
DESCRIPTION broadcast over the protocol, somewhere nobody looks for secrets.
This is INV-TICKET-1's shape a SECOND time. Round 20 found it in `create_ticket` (a tracker
URL with a token echoed into a response `message`), fixed it at that one call site, and
recorded "a fix applied to one call site is not an invariant" in the same commit. So the
redaction now lives once in `_safe_error_text` and both boundary paths use it.
A DENYLIST by design. The exception TYPE and ordinary diagnostics survive intact — a peer
told only "an error occurred" cannot tell a bad argument from an outage, and an over-redacting
channel gets routed around. Hostnames are deliberately NOT redacted (stdio transport, an
operator-configured peer, and a hostname is not replayable the way a credential is); that
trade-off is written down AND asserted, so it is a decision rather than an accident, and the
test says where to revisit it if a network transport ever appears.
Coverage 80% -> 86%; repo total 91.85% -> 92%. The lines still uncovered are the `mcp`
ImportError branch and `main()`/`run()`, which bind real stdio.
FOUR of my own errors, every one caught by a control rather than by review:
1. My first Authorization pattern redacted the word "Bearer" and left the JWT in the clear —
caught by my own parametrised case. A redactor that removes the scheme name and keeps the
credential is worse than none, because the output LOOKS sanitised.
2. Mutation testing then showed "disable the Authorization rule" SURVIVING. Investigating
rather than patching the test: the generic key=value rule also lists "bearer", so it
covered the one case I had written. The rule is NOT redundant — it uniquely handles
`Basic`, `SharedKey`, `Proxy-Authorization` and bare `authorization=`. Added those; the
mutation is now caught by 4 assertions.
3. Adding them immediately failed on `SharedKey acct:<sig>`: my scheme alternation was an
allowlist, so an unlisted scheme was read as the value and the signature survived. The
scheme is now matched generically — a credential redactor must not depend on having
enumerated every auth scheme, since the unfamiliar ones are the risky ones.
4. `AKIAIOSFODNN7EXAMPLE` as a parametrize argument failed the secret scan. Removing it from
the source did NOT fix it: pytest persists parametrised test IDs to
`.pytest_cache/v/cache/nodeids`, and the scanner walks every file. So a secret-shaped
value must never be a parametrize ARGUMENT, not merely never appear in a committed line.
The key id is now assembled at runtime inside the test body. (I also briefly believed
`.pytest_cache` was not gitignored — that was me misreading `git check-ignore`'s exit code
on a path I had just deleted. It is ignored; verified by recreating the directory.)
Mutations 6/6 caught: reverting either boundary path to raw `str(exc)`, neutering the
redactor, dropping the length bound, over-redacting everything, and disabling the
Authorization rule.
Tested: 3806 passed / 6 skipped in BOTH fixed and random order; MCP protocol round-trip E2E
over the real in-memory transport (a raising tool's redacted text verified as the peer
receives it, not just from the helper); scenario E2E 25/25; README CLI E2E 13/13; IaC tsc 0 /
8-of-8 / synth 9 stacks; ruff clean; both mypy gates clean; make ci green.
INV-MCP-4
… in CI PR #35's CI failed on all four Pythons while passing locally. The failure was mine, but diagnosing it uncovered a larger pre-existing defect underneath. ## Defect B (pre-existing, the serious one): CI installed a partial dep list `ci.yml` and `release.yml` both installed their test deps as a hand-copied list: pip install -e . pip install pytest pytest-randomly coverage ruff==0.15.20 hypothesis That is five of the `test` extra's nine entries. The two omitted were **`mcp`** and **`anyio[trio]`** — and `tests/test_mcp_protocol.py` opens with a module-level `importorskip("mcp")`. So on every CI run the ENTIRE MCP protocol E2E layer skipped: 7 tests covering the one surface an untrusted MCP peer reaches. CI reported green. Reproduced, not inferred: a throwaway uv project pinned to CI's exact dependency list gives `mcp: ABSENT`, `anyio: ABSENT`, and `test_mcp_protocol.py` collapsing to `1 skipped`. Local showed **6 skips**, CI showed **12**, and nothing compared the two numbers — which is the whole reason this survived. A skip is the one outcome that looks identical whether the code is fine, the test is broken, or the test never existed. I have reported "MCP protocol E2E green" in several prior rounds. That was true locally and false in CI. Fixed at the source rather than the symptom — adding `mcp` to the list would leave the second source of truth in place and the next dependency would drift the same way: - `pip install -e ".[test]"` cannot drift from pyproject.toml because it IS pyproject.toml - the install step ends with `python -c "import mcp, anyio, trio, ..."`, so a resolver hiccup FAILS the job instead of degrading to a skip - `ruff` stays separate and exactly pinned, so the lint verdict is byte-identical between pre-commit, `make ci` and CI ## Defect A (mine): a new async test file carried neither half of the convention `test_mcp_protocol.py` does two things to make async tests work — a module-level `importorskip("mcp")` AND a local `anyio_backend` fixture. I wrote a sibling file and carried neither. Omitting the fixture does not give "fixture not found": pytest reports `async def functions are not natively supported` and FAILS, and the `importorskip` I put inside the test body never gets to run. It passed locally only because `mcp`/`anyio` are installed here. Which is **this PR's own subject a third time** — "a fix applied to one call site is not an invariant" — now landing on a testing CONVENTION. So it is a check, not a comment: `test_every_async_test_file_pins_a_backend` scans every file using `pytest.mark.anyio`, and fails its own positive control if it finds zero. The fixture must be local, not inherited from anyio's plugin, whose `anyio_backend` is parametrised over every installed backend and would silently run each async test twice. ## A third finding, and a mutation that caught me Syncing the test counts showed `docs/FIDELITY-REPORT.md` claiming "across **137** test files" against a real **157** — 13% understated. `test_docs_drift.py` guards the test COUNT and caught every drift in it, but never captured the file count in the same sentence. Same shape again, at the level of a claim. My first version of that new guard reused `_TEST_COUNT_TOLERANCE = 60`, and the mutation "revert 157 back to 137" **SURVIVED**: ±60 is 1.6% of ~3800 tests (sensible) but ±38% of 157 files, so the guard accepted almost any number while reporting `9 passed`. A tolerance is calibrated to a magnitude; borrowing one across two magnitudes in the same file produces a check that runs and verifies nothing. Now ±3, and the mutation is caught. Had I trusted the green run instead of mutating it, I would have shipped a decorative guard in the very commit whose subject is guards that only look like they work. ## Mutations 8/8 caught, control valid | mutation | | |---|---| | ci.yml reverts to the hand-copied dep list | caught | | release.yml reverts to the hand-copied dep list | caught | | ci.yml drops the post-install import assertion | caught | | ruff loses its exact pin | caught | | the async test file loses its `anyio_backend` fixture | caught | | FIDELITY file count 157 -> 137 | caught *(after the tolerance fix)* | | ROADMAP file count 157 -> 137 | caught | | the file-count claim is deleted entirely (positive control) | caught | ## Testing - **3812 passed / 6 skipped in BOTH fixed and random order** - verified in a REPRODUCED CI environment (uv project with CI's exact deps): the async tests now degrade to a visible SKIP rather than a failure, and the guard still runs - E2E layers: scenario 25/25 · README CLI 13/13 · MCP protocol + redaction 74 combined - `make ci` green · both mypy gates clean · ruff clean · secret scan clean - counts synced 3806 -> 3812 and the stale 137 -> 157 across README / FIDELITY-REPORT / ROADMAP / README-coverage New invariants: **INV-CI-1**, **INV-CI-2**.
…t start the server
With CI finally installing the `test` extra (previous commit), the MCP protocol tests ran
for the first time — and 9 of them failed. The failures were real, and they exposed a
user-facing defect that the silent skip had been hiding all along.
## `pip install sentinel-harness[mcp]` was broken on the current PyPI release
`pyproject.toml` declared `mcp>=1.0` with no upper bound. mcp 2.0.0 is a breaking rewrite
of the low-level server API this package is built on:
mcp 1.28.1 Server("x").list_tools -> present
mcp 2.0.0 Server("x").list_tools -> AttributeError
`create_server()` registers both handlers with `@server.list_tools()` and
`@server.call_tool()`. Verified against a real 2.0.0 install:
AttributeError: 'Server' object has no attribute 'list_tools'
So `pip install sentinel-harness[mcp] && sentinel mcp serve` **could not start at all** for
anyone installing today. Not a test artifact — an install-time break on the default version
a new user gets.
Now `mcp>=1.0,<2` in BOTH the `mcp` and `test` extras. Verified end to end: a fresh resolve
of `sentinel-harness[mcp]` gives mcp 1.29.0 and `create_server()` succeeds.
## What the silent skip was actually hiding
CI never installed `mcp`, so `tests/test_mcp_protocol.py`'s `importorskip("mcp")` fired on
every run and the whole layer skipped. The skip was not concealing a stale test — it was
concealing a **broken published dependency contract**. Those 9 failures were latent from the
day mcp 2.0 shipped; nothing could surface them because the tests that check the contract
never ran. This is the strongest case in this repo for "a skip must never be read as a pass".
## An error of mine worth recording: an import check is not a compatibility check
My first diagnosis was that production code was 2.0-compatible and only a test helper had
moved, because all three imports in `mcp_server.py` still resolve on 2.0.0. I acted on that:
wrote a version-tolerant shim rebuilding the removed
`create_connected_server_and_client_session` from primitives that survive in 2.x, and
concluded pinning would be "the tail wagging the dog".
The shim did not fix the failures. `from mcp.server import Server` resolving proves a module
attribute exists — it says nothing about the shape of the API behind it. Compatibility has
to be probed by CALLING the surface, which is what
`test_the_code_still_depends_on_the_1x_decorator_api` now does. Had I trusted the import
check, I would have shipped an unbounded dependency that cannot start.
## Other changes
- **8 inline imports -> 1**: both test files imported the SDK's session helper at eight
separate call sites. They now route through `tests/mcp_session.py`, so the next SDK break
is fixed in one place. The 2.x fallback is deliberately NOT kept: with `<2` pinned it is
unreachable, and code excluded from ever running is code nobody maintains and everybody
trusts.
- **removed dead `_create_session`** from `test_mcp_protocol.py` — defined, never called,
and the only user of `raise_exceptions=True`.
- the guard asserts the bound's PREMISE, so the pin is lifted deliberately when the code is
ported rather than lingering as a constraint nobody dares touch. To lift it, port to 2.0's
request-handler API and re-verify with the command in the pyproject comment.
## Mutations 4/4 caught, control valid
| mutation | |
|---|---|
| the `mcp` extra loses its upper bound | caught |
| the `test` extra loses its upper bound | caught |
| the two extras disagree on the bound | caught |
| mcp_server stops using `@server.list_tools()` | caught |
## Testing
- **3817 passed / 6 skipped in BOTH fixed and random order**
- verified in a reproduced mcp 2.0.0 environment (the failure mode) AND a fresh
`sentinel-harness[mcp]` resolve (the fix): 1.29.0, `create_server()` OK
- 61 MCP tests green · `make ci` green · both mypy gates clean · ruff clean
- `uv lock --check` clean · counts synced to 3817 / 158 files
New invariant: **INV-MCP-5**.
neosun100
added a commit
that referenced
this pull request
Aug 5, 2026
…laptops (#36) `tests/test_coverage_doc.py` re-measures every figure in `tests/README-coverage.md`. It was written after five of that table's rows were found wrong by 16 to 61 points, every one of them understating the truth. It is the only thing keeping those numbers honest. **It never ran in CI.** CI's test step is `coverage run -m pytest tests`, and coverage writes `.coverage` only when it EXITS — so while the suite is running there is no data file, and those three assertions called `pytest.skip`. They executed on maintainer laptops, where `make ci` had already produced the file, and skipped on every CI run. The guard that keeps the coverage doc honest was only ever verified on the machine of the person who might have let it drift. Measured, not inferred: replicating CI's exact invocation locally reproduces `SKIPPED [3]`. This is INV-CI-1's shape a second time — a check that silently no-ops precisely where it matters — which I flagged in #35 as deserving its own change rather than being bolted on. ## The fix is two-part, because part 1 alone would decay 1. `ci.yml` runs the module as a dedicated step AFTER `coverage report`, when the data exists. 2. That step sets `SENTINEL_REQUIRE_COVERAGE_DATA=1`, under which absent or stale data RAISES instead of skipping. Without part 2 a later change to the data-file path would quietly restore the no-op and the new step would still report green — fixing the symptom while leaving the failure mode intact. A dedicated step that is still allowed to skip is the same failure wearing a different hat. Both unavailable-data paths (missing/stale file, and an unresolvable `coverage` launcher) now route through ONE `_unavailable()` helper, and the guard asserts there is at most one bare `pytest.skip(` left in the module. "A fix applied to one call site is not an invariant" — and I had read that launcher skip as a success three times before it became an assertion. Local developer experience is unchanged: without the flag, a missing `.coverage` still gives the friendly skip with instructions. Over-strictness gets routed around. ## A negative result worth recording Before fixing this I asked the broader question — how many guards in this suite pass vacuously? — and mechanised it: an AST scan for tests that discover a collection, assert it is empty, and never assert it is non-empty. First pass flagged 13. **All 13 were false positives**, and inspecting them taught me my scanner did not understand two legitimate control forms: a `@pytest.mark.parametrize` driving the iteration (an empty set collapses to zero collected tests, which is visible), and a sibling assertion pinning the collection's size. After teaching it both, the count was **0**. So I positive-controlled the scanner itself: planted a deliberately vacuous guard in `tests/` and confirmed it was caught, then removed it. The scanner works and the finding is genuinely zero — this category is clean and needs no change. A scan finding nothing is indistinguishable from a broken scan, so the control is what makes the zero worth reporting. ## Mutations 5/5 caught, control valid | mutation | | |---|---| | ci.yml drops the dedicated coverage-doc step | caught | | the step stops setting the require flag | caught | | the flag is set to a falsey value | caught | | the helper stops raising (skip restored) | caught | | a second bare `pytest.skip` reappears | caught | The falsey-value mutation matters: `bool("false")` is True in Python and this repo has recorded that trap three times (INV-COERCE), so the flag's parsing is asserted both ways — truthy spellings raise, falsey spellings still skip. ## Testing - **3830 passed / 6 skipped in BOTH fixed and random order** - **E2E: the full CI three-step sequence replicated locally** — `coverage run -m pytest` → `coverage report --fail-under=88` (92%) → the new step, which reports **6 passed, ZERO skipped**. That is the assertion this whole change is about, verified as CI will run it. - all four flag/data combinations verified: data+flag passes, no-data+flag FAILS, no-data without flag skips, data without flag passes - `make ci` green · both mypy gates clean · ruff clean · secret scan clean - `README-coverage.md` now states the 9-vs-6 skip delta and WHY, instead of quoting a number that only held under one invocation New invariant: **INV-DOC-5**.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
mcp_server.pywas the lowest-covered core module at 80.3%. The uncovered lines were not incidental — they were the two error paths, and both handed an exception's text to the untrusted MCP peer verbatim.Reproduced
A handler raising
RuntimeError("connect failed: postgresql://svc:SUPERSECRET_PW@db.internal:5432/soc (token=ABSK_LIVE_deadbeef)")delivered the password, the token and the internal hostname to the peer:{"error": "RuntimeError", "message": "connect failed: postgresql://svc:SUPERSECRET_PW@..."}The second path is worse for being quieter — an import-time failure becomes a tool description broadcast over the protocol, somewhere nobody looks for secrets.
This is INV-TICKET-1 a second time
Round 20 found this shape in
create_ticket(a tracker URL with a token echoed into a responsemessage), fixed it at that one call site, and recorded "a fix applied to one call site is not an invariant" in the same commit. The redaction now lives once in_safe_error_textand both boundary paths use it.A denylist by design. The exception type and ordinary diagnostics survive intact — a peer told only "an error occurred" cannot tell a bad argument from an outage, and an over-redacting channel gets routed around.
Hostnames are deliberately not redacted (stdio transport, operator-configured peer, and a hostname is not replayable the way a credential is). That trade-off is written down and asserted, so it is a decision rather than an accident — and the test says where to revisit it if a network transport appears.
Coverage 80% → 86%; repo total 91.85% → 92%. What remains uncovered is the
mcpImportError branch andmain()/run(), which bind real stdio.Four of my own errors, every one caught by a control
My first Authorization pattern redacted the word "Bearer" and left the JWT in the clear — caught by my own parametrised case. A redactor that removes the scheme name and keeps the credential is worse than none, because the output looks sanitised.
Mutation testing then showed "disable the Authorization rule" SURVIVING. Investigating rather than patching the test: the generic key=value rule also lists
bearer, so it covered the one case I had written. The rule is not redundant — it uniquely handlesBasic,SharedKey,Proxy-Authorizationand bareauthorization=. Added those; the mutation is now caught by 4 assertions.Adding them immediately failed on
SharedKey acct:<sig>: my scheme alternation was an allowlist, so an unlisted scheme was read as the value and the signature survived. The scheme is now matched generically — a credential redactor must not depend on having enumerated every auth scheme, since the unfamiliar ones are the risky ones.AKIAIOSFODNN7EXAMPLEas a parametrize argument failed the secret scan — and removing it from the source did not fix it. pytest persists parametrised test IDs to.pytest_cache/v/cache/nodeids, and the scanner walks every file. So a secret-shaped value must never be a parametrize argument, not merely never appear in a committed line. The key id is now assembled at runtime inside the test body.(I also briefly believed
.pytest_cachewas not gitignored — that was me misreadinggit check-ignore's exit code on a path I had just deleted. It is ignored; verified by recreating the directory.)Mutations 6/6 caught
_invoke_toolto rawstr(exc)_safe_error_textTesting
tsc0, 8/8 stack tests,cdk synth9 stacksruffclean · both mypy gates clean ·make cigreenNew invariant: INV-MCP-4.
Update: CI was failing for TWO reasons, and the second was user-facing
The first CI run on this branch failed all four Pythons. Diagnosing it uncovered a
pre-existing defect larger than the one this PR started with.
1. CI installed a partial dependency list, so the MCP E2E layer never ran
ci.ymlandrelease.ymlhand-copied their test deps — five of thetestextra's nineentries. The two omitted were
mcpandanyio[trio], andtest_mcp_protocol.pyopens with
importorskip("mcp"). The entire MCP protocol E2E layer skipped on every CIrun: 7 tests covering the one surface an untrusted peer reaches. CI reported green.
Reproduced in a uv project pinned to CI's exact dep list. Local showed 6 skips, CI showed
12, and nothing compared the two numbers. Fixed at the source —
pip install -e ".[test]"cannot drift from
pyproject.tomlbecause it ISpyproject.toml— plus a post-installpython -c "import mcp, anyio, ..."so a resolver failure FAILS instead of degrading to askip.
ruffstays separately pinned so the lint verdict is byte-identical local vs CI.2.
pip install sentinel-harness[mcp]could not start the serverWith
mcpfinally installed, 9 tests failed for real.pyproject.tomlsaidmcp>=1.0unbounded, and mcp 2.0.0 removed
Server.list_tools()/Server.call_tool()— the twodecorators
create_server()registers its handlers with:Verified against a real 2.0.0 install:
create_server()raisesAttributeError: 'Server' object has no attribute 'list_tools'. So anyone runningpip install sentinel-harness[mcp] && sentinel mcp servetoday got a server that could notstart. Now capped
mcp>=1.0,<2in both extras; a fresh resolve gives 1.29.0 andcreate_server()succeeds.The silent skip was not hiding a stale test — it was hiding a broken published dependency
contract. Those failures were latent from the day mcp 2.0 shipped, and nothing could
surface them because the tests that check the contract never ran.
Three errors of mine, each caught by a control rather than by a reviewer
My new async test file carried neither half of its sibling's convention — no
module-level
importorskip, no localanyio_backendfixture. Omitting the fixture doesnot say "fixture not found"; pytest reports
async def functions are not natively supportedand fails, and animportorskipinside the body never runs. That is this PR'sown subject a third time, landing on a testing CONVENTION — so it is now a check with a
positive control, not a comment.
"An import check is not a compatibility check." I concluded mcp 2.0 compatibility
because all three imports in
mcp_server.pystill resolve, and acted on it — wrote aversion-tolerant shim, and argued in a commit message that pinning would be "the tail
wagging the dog". The shim did not fix the failures. An import resolving proves a module
attribute exists, not that the API behind it has the same shape. The guard now CALLS the
decorator surface.
A guard I wrote passed while verifying nothing. Syncing counts revealed
FIDELITY-REPORT.mdclaiming "137 test files" against a real 157. I added a guard reusing_TEST_COUNT_TOLERANCE = 60— sensible as 1.6% of ~3800 tests, but ±38% of 157 files.The mutation "revert 157 → 137" SURVIVED while the run reported
9 passed. A tolerance iscalibrated to a magnitude; borrowing one across two magnitudes yields a check that runs
and confirms nothing. Now ±3.
Had I trusted the green run instead of mutating it, I would have shipped a decorative
guard in the very commit about guards that only look like they work.
Also
tests/mcp_session.py_create_session(defined, never called, sole user ofraise_exceptions=True)code excluded from ever running is code nobody maintains and everybody trusts
Verification
test_coverage_doc.py(needs a.coveragefile from a prior run — in CI it is being written by the enclosing run) + 2 in
test_lazy_clients.py. Both carry honest reason strings. Left as-is and stated rather thanquietly ignored, since an unexplained local/CI skip delta is exactly what hid defect 1.
New invariants: INV-CI-1, INV-CI-2, INV-MCP-5.