mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 20:27:01 +01:00
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWVUTaKFW8m7YrvWbzV
143 lines
4.9 KiB
Python
143 lines
4.9 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
"""Live check of the todo MCP endpoint: real initialize + list_tasks, or a 401.
|
|
|
|
verify_mcp.py <url> --token-file F [--wait S] # expects a working session
|
|
verify_mcp.py <url> --expect-401 [--wait S] # expects no-token rejection
|
|
|
|
Exit 0 on success, 1 with one reason line on stderr. Used by go_live.sh; the
|
|
token is read from a file and never printed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
_PROTOCOL = "2025-03-26"
|
|
_HEADERS = {
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json, text/event-stream",
|
|
}
|
|
|
|
|
|
class VerifyError(Exception):
|
|
"""A check failed; the message is the one line shown to kuhy."""
|
|
|
|
|
|
def _post(
|
|
url: str, body: dict[str, object], headers: dict[str, str]
|
|
) -> tuple[int, dict[str, str], str]:
|
|
"""POST JSON; return (status, lower-cased headers, body), HTTP errors included."""
|
|
request = urllib.request.Request(
|
|
url, data=json.dumps(body).encode(), headers={**_HEADERS, **headers}
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=15) as resp:
|
|
return (
|
|
resp.status,
|
|
{k.lower(): v for k, v in resp.headers.items()},
|
|
resp.read().decode(),
|
|
)
|
|
except urllib.error.HTTPError as err:
|
|
return err.code, {k.lower(): v for k, v in err.headers.items()}, ""
|
|
|
|
|
|
def _json_of(payload: str) -> dict[str, object]:
|
|
"""Parse a plain JSON body or the first `data:` line of an SSE body."""
|
|
text = payload.strip()
|
|
if not text.startswith("{"):
|
|
lines = [x[5:].strip() for x in text.splitlines() if x.startswith("data:")]
|
|
text = next((x for x in lines if x.startswith("{")), "")
|
|
try:
|
|
parsed = json.loads(text)
|
|
except json.JSONDecodeError as exc:
|
|
msg = f"unparseable MCP reply: {payload[:80]!r}"
|
|
raise VerifyError(msg) from exc
|
|
return parsed if isinstance(parsed, dict) else {}
|
|
|
|
|
|
def check_session(url: str, token: str) -> str:
|
|
"""initialize + notifications/initialized + tools/call list_tasks."""
|
|
auth = {"Authorization": f"Bearer {token}"}
|
|
init = {
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "initialize",
|
|
"params": {
|
|
"protocolVersion": _PROTOCOL,
|
|
"capabilities": {},
|
|
"clientInfo": {"name": "go_live", "version": "1"},
|
|
},
|
|
}
|
|
status, headers, payload = _post(url, init, auth)
|
|
if status != 200:
|
|
msg = f"initialize returned HTTP {status}"
|
|
raise VerifyError(msg)
|
|
if "result" not in _json_of(payload):
|
|
msg = "initialize reply has no result"
|
|
raise VerifyError(msg)
|
|
headers_next = dict(auth)
|
|
if "mcp-session-id" in headers:
|
|
headers_next["Mcp-Session-Id"] = headers["mcp-session-id"]
|
|
_post(url, {"jsonrpc": "2.0", "method": "notifications/initialized"}, headers_next)
|
|
call = {
|
|
"jsonrpc": "2.0",
|
|
"id": 2,
|
|
"method": "tools/call",
|
|
"params": {"name": "list_tasks", "arguments": {}},
|
|
}
|
|
status, _, payload = _post(url, call, headers_next)
|
|
if status != 200:
|
|
msg = f"list_tasks returned HTTP {status}"
|
|
raise VerifyError(msg)
|
|
result = _json_of(payload).get("result")
|
|
if not isinstance(result, dict) or result.get("isError"):
|
|
msg = f"list_tasks failed: {str(result)[:120]}"
|
|
raise VerifyError(msg)
|
|
return "list_tasks ok"
|
|
|
|
|
|
def check_rejected(url: str) -> str:
|
|
"""A request without a bearer token must be refused (401, or 403 from mcp_dart's auth hook)."""
|
|
status, _, _ = _post(url, {"jsonrpc": "2.0", "id": 1, "method": "ping"}, {})
|
|
if status not in (401, 403):
|
|
msg = f"no-token request returned HTTP {status}, expected 401 or 403"
|
|
raise VerifyError(msg)
|
|
return f"no token -> {status}"
|
|
|
|
|
|
def run(argv: list[str]) -> int:
|
|
"""Parse args, retry up to --wait seconds (cold TLS, restart), report."""
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("url")
|
|
parser.add_argument("--token-file", type=Path)
|
|
parser.add_argument("--expect-401", action="store_true")
|
|
parser.add_argument("--wait", type=float, default=0.0)
|
|
args = parser.parse_args(argv)
|
|
if bool(args.token_file) == args.expect_401:
|
|
parser.error("give exactly one of --token-file / --expect-401")
|
|
deadline = time.monotonic() + args.wait
|
|
while True:
|
|
try:
|
|
if args.expect_401:
|
|
print(check_rejected(args.url))
|
|
else:
|
|
token = args.token_file.read_text().strip()
|
|
print(check_session(args.url, token))
|
|
except (VerifyError, OSError) as exc:
|
|
if time.monotonic() >= deadline:
|
|
print(f"{args.url}: {exc}", file=sys.stderr)
|
|
return 1
|
|
time.sleep(2)
|
|
else:
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(run(sys.argv[1:]))
|