Skip to content

fix(stremio_mcp): harden credential handling, library reads, and network I/O - #20

Merged
netixc merged 4 commits into
mainfrom
fm/harden-stremio-mcp-p0-r1
Jul 20, 2026
Merged

fix(stremio_mcp): harden credential handling, library reads, and network I/O#20
netixc merged 4 commits into
mainfrom
fm/harden-stremio-mcp-p0-r1

Conversation

@netixc

@netixc netixc commented Jul 20, 2026

Copy link
Copy Markdown
Owner

Intent

Implement the first three P0 fixes (F1, F2, F3) from a read-only security audit of the stremio-mcp MCP server, then validate them. This is security-hardening work on a released 0.1.0 package.

F1: Prevent TMDB credentials from entering logs on HTTP, timeout, JSON, or connection failures. Previously requests' raise_for_status() produced exception text containing the prepared URL (with api_key in the query string) and broad handlers logged it verbatim. Deliberate approach: describe every network failure only by facts the module constructs itself (category/host/status code), never a prepared URL, request payload, or raw upstream exception; plus a SecretRedactingFilter installed on the server logger, root handlers, and the httpx/httpcore loggers as a backstop for tracebacks and third-party request-line logs. The audit asked to 'prefer an authorization form that does not place secrets in URLs when compatible' - TMDB only accepts a v4 read access token (a JWT) as an Authorization: Bearer header, while a legacy v3 key has no header form and must remain a query parameter, so the credential form is detected from the credential itself rather than assumed. This is intentional, not an oversight: v3 keys still travel in the query string, which is exactly why no failure path may report a URL. Sentinel-secret regression tests prove credentials and secret-bearing query strings never appear in logs or returned errors.

F2: Replace ambiguous Stremio library reads with typed outcomes. Previously _make_request collapsed every transport/HTTP/JSON/API error to {} and get_library_item collapsed both an error and a successful not-found to None, so add_to_library treated a transient read failure as proof the item did not exist and overwrote existing watch state with default state; get_library_item also trusted the first returned row without checking its _id. Deliberate approach: LibraryRead/LibraryListRead/MetaRead dataclasses with a ReadStatus enum that separates FOUND, authoritative NOT_FOUND, and ERROR. Reads fail closed - anything other than exactly one row whose _id equals the requested ID is an ERROR, including duplicate rows, unrequested extra rows, non-dict rows, and unexpected response shapes. Every mutation aborts without writing on ERROR, verifies exact identity and expected content type, and preserves watch state. Writes abort when the write request itself fails and when the verification read cannot confirm identity, type, removal state, and watch state (a state difference is treated as a read/write race and reported as failure rather than claimed as success). Fault-injection tests cover timeouts, HTTP/API/JSON errors, empty success, mismatched identity, duplicate rows, and a read/write race.

F3: Replace synchronous timeout-free network work in async MCP handlers. Previously all three TMDB GET paths used a synchronous requests.Session with no timeout, called directly from the async MCP dispatcher, so one slow call blocked the event loop and froze unrelated device/playback controls; an auto search was also an N+1 of up to 12 serial requests. Deliberate approach: one lifecycle-managed AsyncHTTPClient wrapping httpx.AsyncClient with explicit bounded connect/read/write/pool timeouts, a bounded response body read via streaming, a bounded connection pool, and cancellation support; external-ID fan-out runs concurrently under a semaphore. Bounds are configurable via STREMIO_MCP_* env vars whose bad values are reported by variable name without echoing the value.

Deliberate decisions a reviewer reading only the diff would not know:

  • Documented compatibility change: TMDBClient and StremioAPIClient methods are now coroutines taking a shared AsyncHTTPClient, and the requests dependency is replaced by httpx (already a transitive MCP dependency). The audit's own remediation for F3 requires this; it is recorded in CHANGELOG.md under Unreleased/Changed.
  • TMDB search methods now raise HTTPClientError instead of returning [] so the dispatcher can distinguish 'service unavailable' from 'no results'. For type=auto specifically, a failure of one half returns the other half's results plus an explicit '(partial results - ...)' note rather than discarding working results or passing an outage off as 'No results found'.
  • Some checks that would duplicate a guarantee already enforced by read_library_item (which only reports FOUND for exactly one exactly-matching row) were deliberately removed rather than left as unreachable defensive code; a comment records the guarantee.
  • Playback parsing (F4), the play conditional schema (F5), volume/ADB recovery (F6/F7), isError/annotations (F8), release metadata (F9), and port validation (F10) are audit findings P1 and lower and are intentionally OUT OF SCOPE for this change. In particular ANDROID_TV_PORT still uses a bare int() at import even though a bounded _env_int helper now exists nearby - that is deliberate scope discipline, not an inconsistency.
  • Tests must never use real secrets; all credentials in tests are clearly synthetic sentinels and no test contacts TMDB, Stremio, Cinemeta, or a device.

Verification already run locally and passing: uv sync --locked, 86 unit tests (up from 30), compileall, uv build, uv lock --check, uv pip check, built-wheel console startup with all sensitive/device configuration blank, and a focused MCP protocol-level session confirming tools/list, schema rejection, and that device controls answer in ~2ms while a TMDB request is stalled mid-flight.

What Changed

  • Credential leak prevention (F1): network failures are now described only by facts the module builds itself (category/host/status code) — never a prepared URL, request payload, or raw upstream exception — and a SecretRedactingFilter is installed on the server logger, root handlers, and the httpx/httpcore loggers as a backstop. TMDB auth form is detected from the credential itself (v4 read token → Authorization: Bearer; legacy v3 key stays a query parameter).
  • Fail-closed Stremio library reads (F2): reads return typed LibraryRead/LibraryListRead/MetaRead outcomes with a ReadStatus enum separating FOUND, authoritative NOT_FOUND, and ERROR, so a transient read failure can no longer be mistaken for "item absent". Anything other than exactly one row whose _id matches the request is an ERROR; mutations abort without writing on ERROR, verify identity/type/removal/watch state after the write, and preserve existing watch state on re-add.
  • Async, bounded HTTP (F3): the synchronous timeout-free requests session is replaced by a single lifecycle-managed AsyncHTTPClient over httpx with explicit connect/read/write/pool timeouts, a bounded streamed response body, a bounded pool, and cancellation support; external-ID lookups fan out concurrently under a semaphore instead of running up to 12 serial requests. Bounds are configurable via STREMIO_MCP_* env vars whose bad values are reported by variable name only. TMDB searches now raise HTTPClientError so the dispatcher can distinguish an outage from "no results", and type=auto returns the working half with an explicit (partial results — …) note.

Compatibility: TMDBClient/StremioAPIClient methods are now coroutines taking a shared AsyncHTTPClient, and requests is replaced by httpx (already a transitive MCP dependency); recorded in CHANGELOG.md under Unreleased/Changed. Review feedback was folded in: redirect and httpx.StreamError responses get typed categories, the Stremio API error code is surfaced, write verification compares only the state keys this module writes, and the error-erasing legacy wrappers were removed.

Audit findings F4–F10 are P1 and lower and are intentionally out of scope.

Verification: uv sync --locked, 91 unit tests, compileall, uv build, and uv lock --check all pass, plus an end-to-end MCP-protocol harness (real server subprocess over stdio, fake TMDB/Stremio/Cinemeta upstream, stub adb) covering a sentinel-secret leak check on a TMDB 401, a faulted-read check asserting zero datastorePut requests, a soft-deleted re-add watch-state comparison, and a concurrency check that device controls answer while a TMDB request is stalled.

Risk Assessment

✅ Low: The follow-up commit resolves every round-1 finding with narrowly scoped, well-tested changes that preserve the typed fail-closed and credential-redaction invariants, and no new issues surfaced in a full re-review of the delta and its surrounding call sites.

Testing

Ran the full CI-equivalent set (locked sync, 91 unit tests, compileall, build, lock check) and then drove the actual MCP server as a subprocess over stdio against a local fake TMDB/Stremio/Cinemeta upstream with synthetic sentinel credentials and a stub adb, capturing a JSON transcript plus the server's stderr: a TMDB 401 surfaced as a category/host/status-only error with the sentinel key provably on the wire but absent from every log line and response; a faulted library read made both check and add fail closed with zero datastorePut requests sent, while a successful re-add wrote back the exact prior watch state; and a device control call answered in ~0.3s while a 6s TMDB request was stalled mid-flight, showing the event loop is no longer blocked. No UI surface is involved (stdio MCP server), so the end-user artifacts are the protocol transcript and server log rather than screenshots. All checks passed and build artifacts were cleaned from the worktree.

Evidence: End-to-end MCP session transcript (F1/F2/F3 evidence)
[
  {
    "label": "tools/list",
    "tools": [
      "search",
      "play",
      "library",
      "tv_control",
      "playback_status"
    ]
  },
  {
    "label": "F1: TMDB 401 error surface",
    "tool": "search",
    "arguments": {
      "query": "leak-test",
      "type": "movie"
    },
    "elapsed_s": 0.022,
    "response_text": "Error: upstream request failed (category=http_status host=127.0.0.1 status=401)",
    "raw": {
      "jsonrpc": "2.0",
      "id": 3,
      "result": {
        "content": [
          {
            "type": "text",
            "text": "Error: upstream request failed (category=http_status host=127.0.0.1 status=401)"
          }
        ],
        "isError": false
      }
    }
  },
  {
    "label": "F2: read fault -> check",
    "tool": "library",
    "arguments": {
      "action": "check",
      "imdb_id": "tt0068646"
    },
    "elapsed_s": 0.003,
    "response_text": "Error: library unavailable (category=http_status host=127.0.0.1 status=503).",
    "raw": {
      "jsonrpc": "2.0",
      "id": 4,
      "result": {
        "content": [
          {
            "type": "text",
            "text": "Error: library unavailable (category=http_status host=127.0.0.1 status=503)."
          }
        ],
        "isError": false
      }
    }
  },
  {
    "label": "F2: read fault -> add must fail closed (no write)",
    "tool": "library",
    "arguments": {
      "action": "add",
      "type": "movie",
      "imdb_id": "tt0068646"
    },
    "elapsed_s": 0.002,
    "response_text": "Failed to add tt0068646: library read failed (category=http_status host=127.0.0.1 status=503)",
    "raw": {
      "jsonrpc": "2.0",
      "id": 5,
      "result": {
        "content": [
          {
            "type": "text",
            "text": "Failed to add tt0068646: library read failed (category=http_status host=127.0.0.1 status=503)"
          }
        ],
        "isError": false
      }
    }
  },
  {
    "label": "F2: datastorePut calls during failed-read add",
    "puts_before": 0,
    "puts_after": 0
  },
  {
    "label": "F2: re-add preserves watch state",
    "tool": "library",
    "arguments": {
      "action": "add",
      "type": "movie",
      "imdb_id": "tt0111161"
    },
    "elapsed_s": 0.005,
    "response_text": "The Shawshank Redemption: re-added",
    "raw": {
      "jsonrpc": "2.0",
      "id": 6,
      "result": {
        "content": [
          {
            "type": "text",
            "text": "The Shawshank Redemption: re-added"
          }
        ],
        "isError": false
      }
    }
  },
  {
    "label": "F2: datastorePut payload written to Stremio",
    "written_id": "tt0111161",
    "written_removed": false,
    "written_state": {
      "lastWatched": "2026-07-01T10:00:00Z",
      "timeWatched": 3600000,
      "timeOffset": 1800000,
      "overallTimeWatched": 3600000,
      "timesWatched": 1,
      "flaggedWatched": 0,
      "duration": 8520000,
      "video_id": "tt0111161",
      "watched": null,
      "noNotif": false
    },
    "state_preserved": true,
    "authkey_in_body": true
  },
  {
    "label": "F3: device control while TMDB request stalled 6s",
    "device_response": "Volume increased",
    "device_latency_s": 0.298,
    "device_answered_before_stalled_search": true,
    "stalled_search_total_s": 6.014,
    "stalled_search_response": "No results found."
  },
  {
    "label": "F1: leak audit",
    "tmdb_sentinel_in_server_log": false,
    "stremio_sentinel_in_server_log": false,
    "tmdb_sentinel_in_mcp_responses": false,
    "stremio_sentinel_in_mcp_responses": false,
    "api_key_param_in_server_log": false,
    "upstream_url_in_server_log": false
  },
  {
    "label": "F1: credential really was sent upstream (v3 key -> query param)",
    "example_query": "query=leak-test&include_adult=false&api_key=SENTINEL-TMDB-KEY-3f9a2c7e11",
    "sentinel_on_wire": true
  }
]
Evidence: Server stderr during the MCP session (no credential or URL present)

INFO:stremio-mcp:Stremio library access enabled ERROR:stremio-mcp:TMDB movie search failed: category=http_status host=127.0.0.1 status=401 ERROR:stremio-mcp:Network failure in tool 'search': category=http_status host=127.0.0.1 status=401 ERROR:stremio-mcp:Stremio API request failed (datastoreGet): category=http_status host=127.0.0.1 status=503 INFO:stremio-mcp:Connected to Android TV at 127.0.0.1:5555

INFO:stremio-mcp:Stremio library access enabled
INFO:mcp.server.lowlevel.server:Processing request of type ListToolsRequest
INFO:mcp.server.lowlevel.server:Processing request of type CallToolRequest
ERROR:stremio-mcp:TMDB movie search failed: category=http_status host=127.0.0.1 status=401
ERROR:stremio-mcp:Network failure in tool 'search': category=http_status host=127.0.0.1 status=401
INFO:mcp.server.lowlevel.server:Processing request of type CallToolRequest
ERROR:stremio-mcp:Stremio API request failed (datastoreGet): category=http_status host=127.0.0.1 status=503
INFO:mcp.server.lowlevel.server:Processing request of type CallToolRequest
ERROR:stremio-mcp:Stremio API request failed (datastoreGet): category=http_status host=127.0.0.1 status=503
INFO:mcp.server.lowlevel.server:Processing request of type CallToolRequest
INFO:mcp.server.lowlevel.server:Processing request of type CallToolRequest
INFO:mcp.server.lowlevel.server:Processing request of type CallToolRequest
INFO:stremio-mcp:Connected to Android TV at 127.0.0.1:5555
Evidence: Harness used to produce the evidence
#!/usr/bin/env python3
"""End-to-end MCP-protocol harness for the P0 F1/F2/F3 hardening.

Runs the real stremio-mcp server as a subprocess speaking MCP over stdio,
against a local fake TMDB / Stremio API / Cinemeta upstream. No real
credentials, no real network, no real device: the TMDB key and Stremio auth
key are synthetic sentinels and `adb` is a stub script.
"""

import json
import os
import re
import subprocess
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs

EVIDENCE = os.path.dirname(os.path.abspath(__file__))
REPO = sys.argv[1]

TMDB_SENTINEL = "SENTINEL-TMDB-KEY-3f9a2c7e11"
STREMIO_SENTINEL = "SENTINEL-STREMIO-AUTHKEY-b41d8e"

requests_seen = []          # (method, path, query, body)
requests_lock = threading.Lock()

# Library row that already carries real watch state; the add path must preserve it.
WATCHED_STATE = {
    "lastWatched": "2026-07-01T10:00:00Z",
    "timeWatched": 3600000,
    "timeOffset": 1800000,
    "overallTimeWatched": 3600000,
    "timesWatched": 1,
    "flaggedWatched": 0,
    "duration": 8520000,
    "video_id": "tt0111161",
    "watched": None,
    "noNotif": False,
}
STORE = {
    "tt0111161": {
        "_id": "tt0111161",
        "name": "The Shawshank Redemption",
        "type": "movie",
        "poster": "http://poster",
        "posterShape": "poster",
        "removed": True,          # soft-deleted -> "re-added" path
        "temp": False,
        "_ctime": "2025-01-01T00:00:00Z",
        "_mtime": "2026-07-01T10:00:00Z",
        "state": dict(WATCHED_STATE),
        "behaviorHints": {},
    }
}
# datastoreGet for this id always fails (fault injection for F2)
FAULTY_ID = "tt0068646"


class Handler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def log_message(self, *a):  # silence stderr noise
        pass

    def _send(self, code, obj):
        body = json.dumps(obj).encode()
        self.send_response(code)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def _record(self, method, path, query, body):
        with requests_lock:
            requests_seen.append(
                {"method": method, "path": path, "query": query, "body": body,
                 "t": round(time.monotonic() - T0, 3)}
            )

    def do_GET(self):
        u = urlparse(self.path)
        q = parse_qs(u.query)
        self._record("GET", u.path, u.query, None)

        # --- TMDB ---
        if u.path == "/3/search/movie":
            query = (q.get("query") or [""])[0]
            if query == "leak-test":
                # 401 from TMDB: exception text used to carry the prepared URL
                return self._send(401, {"status_message": "Invalid API key"})
            if query == "stall-test":
                time.sleep(6)
                return self._send(200, {"results": []})
            return self._send(200, {"results": [
                {"id": 278, "title": "The Shawshank Redemption",
                 "release_date": "1994-09-23", "overview": "Two imprisoned men bond."},
            ]})
        if u.path == "/3/search/tv":
            return self._send(200, {"results": []})
        if re.fullmatch(r"/3/movie/\d+/external_ids", u.path):
            return self._send(200, {"imdb_id": "tt0111161"})

        # --- Cinemeta ---
        m = re.fullmatch(r"/meta/(movie|series)/(tt\d+)\.json", u.path)
        if m:
            return self._send(200, {"meta": {
                "id": m.group(2), "type": m.group(1),
                "name": "The Shawshank Redemption", "poster": "http://poster",
                "behaviorHints": {},
            }})
        return self._send(404, {"error": "not found"})

    def do_POST(self):
        u = urlparse(self.path)
        length = int(self.headers.get("Content-Length", 0))
        raw = self.rfile.read(length)
        try:
            body = json.loads(raw)
        except ValueError:
            body = {"<unparsed>": raw.decode(errors="replace")}
        self._record("POST", u.path, u.query, body)

        if u.path == "/api/datastoreGet":
            ids = body.get("ids") or []
            if body.get("all"):
                return self._send(200, {"result": list(STORE.values())})
            if FAULTY_ID in ids:
                # transport-level fault: upstream 503
                return self._send(503, {"error": {"code": 503}})
            rows = [STORE[i] for i in ids if i in STORE]
            return self._send(200, {"result": rows})

        if u.path == "/api/datastorePut":
            for change in body.get("changes", []):
                STORE[change["_id"]] = change
            return self._send(200, {"result": {"success": True}})

        return self._send(404, {"error": "not found"})


T0 = time.monotonic()
srv = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
PORT = srv.server_address[1]
threading.Thread(target=srv.serve_forever, daemon=True).start()

# --- stub adb so device control is exercised without a real device ---
fake_adb = os.path.join(EVIDENCE, "fake-adb")
with open(fake_adb, "w") as fh:
    fh.write("#!/bin/sh\n"
             'case "$1" in connect) echo \"connected to $2\";; *) echo Success;; esac\n'
             "exit 0\n")
os.chmod(fake_adb, 0o755)

BOOT = f'''
import asyncio, sys
sys.path.insert(0, {REPO + "/src"!r})
import stremio_mcp as m
m.TMDBClient.BASE_URL = "http://127.0.0.1:{PORT}/3"
m.StremioAPIClient.API_URL = "http://127.0.0.1:{PORT}"
m.StremioAPIClient.CINEMETA_URL = "http://127.0.0.1:{PORT}"
asyncio.run(m.main())
'''

env = dict(os.environ)
env.update({
    "TMDB_API_KEY": TMDB_SENTINEL,
    "STREMIO_AUTH_KEY": STREMIO_SENTINEL,
    "ANDROID_TV_HOST": "127.0.0.1",
    "ANDROID_TV_PORT": "5555",
    "ADB_PATH": fake_adb,
    "PYTHONUNBUFFERED": "1",
})

stderr_path = os.path.join(EVIDENCE, "mcp-server-stderr.log")
stderr_fh = open(stderr_path, "wb")
proc = subprocess.Popen(
    [os.path.join(REPO, ".venv/bin/python"), "-c", BOOT],
    stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=stderr_fh, env=env,
)

_next_id = [0]
transcript = []


def send(method, params=None, notify=False):
    msg = {"jsonrpc": "2.0", "method": method}
    if params is not None:
        msg["params"] = params
    if not notify:
        _next_id[0] += 1
        msg["id"] = _next_id[0]
    proc.stdin.write((json.dumps(msg) + "\n").encode())
    proc.stdin.flush()
    return msg.get("id")


def recv():
    line = proc.stdout.readline()
    if not line:
        raise RuntimeError("server closed stdout")
    return json.loads(line)


def call(name, args, label):
    t = time.monotonic()
    send("tools/call", {"name": name, "arguments": args})
    resp = recv()
    dt = time.monotonic() - t
    text = "".join(c.get("text", "") for c in resp.get("result", {}).get("content", []))
    transcript.append({"label": label, "tool": name, "arguments": args,
                       "elapsed_s": round(dt, 3), "response_text": text,
                       "raw": resp})
    return text, dt


send("initialize", {"protocolVersion": "2024-11-05", "capabilities": {},
                    "clientInfo": {"name": "evidence-harness", "version": "1"}})
init = recv()
send("notifications/initialized", {}, notify=True)
send("tools/list", {})
tools = recv()
transcript.append({"label": "tools/list",
                   "tools": [t["name"] for t in tools["result"]["tools"]]})

# ---- F1: TMDB 401 must not leak the api_key ------------------------------
call("search", {"query": "leak-test", "type": "movie"}, "F1: TMDB 401 error surface")

# ---- F2a: library read failure -> check reports unavailable ---------------
call("library", {"action": "check", "imdb_id": FAULTY_ID}, "F2: read fault -> check")

# ---- F2b: add when the read fails must NOT write --------------------------
with requests_lock:
    puts_before = sum(1 for r in requests_seen if r["path"] == "/api/datastorePut")
call("library", {"action": "add", "type": "movie", "imdb_id": FAULTY_ID},
     "F2: read fault -> add must fail closed (no write)")
with requests_lock:
    puts_after = sum(1 for r in requests_seen if r["path"] == "/api/datastorePut")
transcript.append({"label": "F2: datastorePut calls during failed-read add",
                   "puts_before": puts_before, "puts_after": puts_after})

# ---- F2c: re-add of a soft-deleted item preserves watch state -------------
call("library", {"action": "add", "type": "movie", "imdb_id": "tt0111161"},
     "F2: re-add preserves watch state")
with requests_lock:
    put = [r for r in requests_seen if r["path"] == "/api/datastorePut"][-1]
written = put["body"]["changes"][0]
transcript.append({
    "label": "F2: datastorePut payload written to Stremio",
    "written_id": written["_id"], "written_removed": written["removed"],
    "written_state": written["state"],
    "state_preserved": written["state"] == WATCHED_STATE,
    "authkey_in_body": STREMIO_SENTINEL in json.dumps(put["body"]),
})

# ---- F3: device control answers while a TMDB call is stalled --------------
t_stall = time.monotonic()
send("tools/call", {"name": "search", "arguments": {"query": "stall-test",
                                                    "type": "movie"}})
time.sleep(0.5)  # ensure the TMDB request is genuinely in flight
t_dev = time.monotonic()
send("tools/call", {"name": "tv_control", "arguments": {"category": "volume",
                                                        "action": "up"}})
first = recv()
first_at = time.monotonic()
second = recv()
second_at = time.monotonic()
by_id = {first["id"]: (first, first_at), second["id"]: (second, second_at)}
dev_resp, dev_at = by_id[max(by_id)]
stall_resp, stall_at = by_id[min(by_id)]
transcript.append({
    "label": "F3: device control while TMDB request stalled 6s",
    "device_response": "".join(c.get("text", "") for c in
                               dev_resp["result"]["content"]),
    "device_latency_s": round(dev_at - t_dev, 3),
    "device_answered_before_stalled_search": dev_at < stall_at,
    "stalled_search_total_s": round(stall_at - t_stall, 3),
    "stalled_search_response": "".join(c.get("text", "") for c in
                                       stall_resp["result"]["content"]),
})

proc.stdin.close()
try:
    proc.wait(timeout=10)
except subprocess.TimeoutExpired:
    proc.kill()
stderr_fh.close()
srv.shutdown()

log = open(stderr_path, "rb").read().decode(errors="replace")
all_text = json.dumps(transcript)
leaks = {
    "tmdb_sentinel_in_server_log": TMDB_SENTINEL in log,
    "stremio_sentinel_in_server_log": STREMIO_SENTINEL in log,
    "tmdb_sentinel_in_mcp_responses": TMDB_SENTINEL in all_text,
    "stremio_sentinel_in_mcp_responses": STREMIO_SENTINEL in all_text,
    "api_key_param_in_server_log": "api_key=" in log.replace("api_key=***REDACTED***", ""),
    "upstream_url_in_server_log": "127.0.0.1:%d" % PORT in log,
}
transcript.append({"label": "F1: leak audit", **leaks})

# proof the credential really was on the wire (so the no-leak result is meaningful)
with requests_lock:
    tmdb_hits = [r for r in requests_seen if r["path"].startswith("/3/")]
transcript.append({
    "label": "F1: credential really was sent upstream (v3 key -> query param)",
    "example_query": tmdb_hits[0]["query"] if tmdb_hits else None,
    "sentinel_on_wire": any(TMDB_SENTINEL in (r["query"] or "") for r in tmdb_hits),
})

out = os.path.join(EVIDENCE, "mcp-e2e-transcript.json")
with open(out, "w") as fh:
    json.dump(transcript, fh, indent=2)
os.remove(fake_adb)
print(json.dumps(transcript, indent=2))
Evidence: Key end-user-visible outcomes from the MCP session
F1 search(leak-test) -> "Error: upstream request failed (category=http_status host=127.0.0.1 status=401)"
credential on wire: query=leak-test&include_adult=false&api_key=SENTINEL-TMDB-KEY-3f9a2c7e11
sentinel in server log: false | sentinel in MCP responses: false | upstream URL in log: false

F2 library check (read 503) -> "Error: library unavailable (category=http_status host=127.0.0.1 status=503)."
library add (read 503) -> "Failed to add tt0068646: library read failed (...status=503)"
datastorePut requests during that add: 0 (before=0, after=0)
library add tt0111161 (soft-deleted, had watch state) -> "The Shawshank Redemption: re-added"
written state == pre-existing watch state: true (timeWatched=3600000, timeOffset=1800000, video_id=tt0111161)

F3 TMDB search stalled 6s in flight; tv_control volume up -> "Volume increased" in 0.298s
device answered before stalled search: true | stalled search returned at 6.014s

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 5 issues found → auto-fixed ✅
  • ⚠️ src/stremio_mcp.py:719 - request_json only treats status_code &gt;= 400 as an error, but the client is built with follow_redirects=False (line 667). A 3xx response therefore falls through to json.loads(body) on an empty/HTML redirect body and is reported as category=invalid_json, which is misleading and also a silent behavior change from the previous requests client (which followed redirects by default). Add an explicit 300 &lt;= status_code &lt; 400 branch raising a redirect category (or enable bounded redirect following).
  • ⚠️ src/stremio_mcp.py:974 - A Stremio API-level error is collapsed to category=api_error kind=dict, discarding every diagnosable fact. The common real case — an expired or revoked STREMIO_AUTH_KEY — surfaces to the user as Error: library unavailable (category=api_error kind=dict) with no indication that re-authentication is needed. Stremio's error object carries a numeric code that is server-generated and credential-free; including it (e.g. code=1 alongside kind=) keeps the no-echo guarantee while making the failure actionable.
  • ⚠️ src/stremio_mcp.py:1211 - put_library_item requires persisted.get(&#34;state&#34;) == item.get(&#34;state&#34;) by exact dict equality. If the Stremio datastore normalizes the echoed state at all (drops a null-valued key, adds a server-side field, coerces a type), a write that actually succeeded is reported as write verification state conflict and library add returns Failed to add ... — prompting the user to retry a mutation that already landed. The intent records this fail-closed choice deliberately, so flagging rather than changing: consider comparing only the state keys this module writes, or reporting a distinct 'written but state differs' outcome instead of a flat failure.
  • ℹ️ src/stremio_mcp.py:1043 - get_library_item (1043), get_library (1066), get_continue_watching (1098) and search_library (1118) survive as wrappers that collapse ERROR back into None/[] — exactly the ambiguity F2 removes. They have no call sites left in src/; only tests exercise them (one test even names the behavior ..._hides_errors_as_none). Since the CHANGELOG already declares the client classes breaking-changed, keeping error-erasing wrappers is a live footgun for the next caller. Consider removing them or documenting them as deprecated.
  • ℹ️ src/stremio_mcp.py:714 - request_json catches httpx.TimeoutException and httpx.HTTPError, but httpx.StreamError (and its subclasses, which derive from RuntimeError, not HTTPError) can escape from response.aiter_bytes() in _read_bounded. Such a failure bypasses the typed HTTPClientError contract and lands in the dispatcher's generic except Exception handler, so callers like add_to_library see an unexpected exception rather than a categorized transport error. Adding httpx.StreamError to the connection branch closes the gap.

🔧 Fix: Type redirect/stream errors, expose API error code, scope state verify, drop wrappers
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • uv sync --locked
  • uv run --locked python -m unittest discover -s tests -v (91 tests, all pass)
  • uv run --locked python -m compileall -q src tests
  • uv build and uv lock --check
  • End-to-end MCP-protocol harness: real server subprocess over stdio + fake TMDB/Stremio/Cinemeta HTTP upstream + stub adb (e2e_mcp_harness.py)
  • F1 live check: tools/call search {query: leak-test} against a TMDB 401, then grep of server stderr and all MCP responses for the sentinel API key and for api_key= / upstream URL
  • F2 live check: tools/call library {action: check|add, imdb_id: tt0068646} with datastoreGet faulted to 503, asserting zero /api/datastorePut requests reached the upstream
  • F2 live check: tools/call library {action: add, type: movie, imdb_id: tt0111161} re-adding a soft-deleted item, comparing the captured datastorePut payload's state block against the pre-existing watch state
  • F3 live check: concurrent tools/call search {query: stall-test} (upstream sleeps 6s) and tools/call tv_control {category: volume, action: up}, measuring per-call latency and response ordering
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

control and others added 4 commits July 20, 2026 20:13
Implements the three P0 fixes from the read-only security audit.

F1 - TMDB credentials can no longer reach logs or returned errors.
Network failures are now described only by a category, host, and status
code that this module constructs; no path logs a prepared URL, a request
payload, or a raw upstream exception. A redaction filter installed on the
server logger, the root handlers, and the httpx/httpcore loggers strips
registered credentials, secret-bearing query parameters, and Authorization
values from every log record, traceback, and tool result. A TMDB v4 read
access token is now sent as an Authorization header so it never enters a
URL; a legacy v3 key has no header form and is still a query parameter,
which is why no failure path reports one.

F2 - Library reads return typed outcomes (LibraryRead, LibraryListRead,
MetaRead) that separate found, an authoritative not-found, and an error.
Mutations fail closed: add and remove abort without writing on any read
error, _id mismatch, duplicate row, unrequested extra row, or content-type
mismatch, so a transient failure can no longer be read as absence and reset
existing watch state. Writes abort when the write request itself fails and
when verification cannot confirm identity, type, removal state, and watch
state. The dispatcher now reports an unavailable library separately from an
empty one for list, continue, search, check, and library-sourced play.

F3 - All HTTP work moves to one lifecycle-managed async httpx client with
explicit connect/read/write/pool timeouts, a bounded response body, a
bounded connection pool, and cancellation. Previously TMDB requests were
synchronous with no timeout and blocked the MCP event loop, freezing
unrelated device controls. Automatic searches resolve external IDs
concurrently under a bounded semaphore instead of up to ten serial
requests, and an auto search reports a half-outage rather than passing it
off as "no results".

Adds 56 tests covering sentinel-secret non-disclosure across HTTP, timeout,
JSON and connection faults; bearer vs query authorization; library fault
injection for every failure category, empty success, mismatched identity,
duplicate rows and a read/write race; and transport timeouts, cancellation,
bounded size, bounded fan-out, and device-control responsiveness during a
stalled network call.

Compatibility: TMDBClient and StremioAPIClient methods are now coroutines
taking a shared AsyncHTTPClient, and requests is replaced by httpx (already
a transitive MCP dependency). Documented in CHANGELOG.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@netixc
netixc merged commit 45c269d into main Jul 20, 2026
6 checks passed
@netixc
netixc deleted the fm/harden-stremio-mcp-p0-r1 branch July 25, 2026 14:02
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