diff --git a/mcp/go_live.sh b/mcp/go_live.sh new file mode 100755 index 0000000..8f37943 --- /dev/null +++ b/mcp/go_live.sh @@ -0,0 +1,163 @@ +#!/bin/bash +# One command that takes the todo MCP server live. Idempotent; rerun any time. +# +# mcp/go_live.sh # seed, token, install, publish, verify, cloud token +# mcp/go_live.sh --verify-only # only step 5 (live MCP calls + 401 check) +# mcp/go_live.sh --dry-run # print the plan, change nothing +# mcp/go_live.sh --new-cloud-token # rotate the cloud token even if one exists +# +# Needs: python3, dart/flutter (install.sh), docker (Caddy), xclip, curl, ss. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +readonly ROOT +readonly PORT=8735 +readonly LOCAL_URL="http://127.0.0.1:${PORT}/mcp" +readonly PUBLIC_URL="https://todo.kuhy.duckdns.org/mcp" +readonly CONFIG_DIR="${HOME}/.config/todo-mcp" +readonly CRED_FILE="${CONFIG_DIR}/firebase_auth.json" +readonly TOKENS="${CONFIG_DIR}/tokens" +# Not under todo-mcp/: publish_caddy_site.sh would treat ~/.config/todo-mcp/token +# as an edge bearer gate and break the cloud token. +readonly CLIENT_TOKEN="${HOME}/.config/todo-mcp-client/token" +readonly ZSHENV="${HOME}/.zshenv" +# shellcheck disable=SC2016 # the line is written literally; zsh expands it later +readonly ZSHENV_LINE='export TODO_MCP_TOKEN="$(cat ~/.config/todo-mcp-client/token)"' +readonly SEED_DIR="${HOME}/src/utils/crdt-sync" +readonly SECRET_FILE="${HOME}/.config/crdt-sync/oauth_client_secret" +readonly CLIENT_ID="845446124781-prdoherj0v64vc6egvvcp3l0693khaur.apps.googleusercontent.com" +readonly PUBLISH="${HOME}/src/testsAndMisc/linux_configuration/features/publish_caddy_site.sh" +readonly VERIFY="${ROOT}/mcp/verify_mcp.py" + +DRY_RUN=0 +VERIFY_ONLY=0 +NEW_CLOUD=0 + +say() { echo "[$1] $2"; } +die() { echo "FAIL: $*" >&2; exit 1; } +has_hash() { [[ -f "$TOKENS" ]] && awk -v e="$1" '$2 == e {f=1} END {exit !f}' "$TOKENS"; } + +step_seed() { + if [[ -f "$CRED_FILE" ]]; then say 1/6 "seed: credentials exist, skipped"; return; fi + if ((DRY_RUN)); then say 1/6 "seed: would run seed_session --app todo-mcp (consent tap)"; return; fi + [[ -f "$SECRET_FILE" ]] || die "no $SECRET_FILE; run ~/src/utils/crdt-sync/tool/refresh_oauth_secret.sh --app todo-mcp" + say 1/6 "seed: sign in with Google when the browser opens" + (cd "$SEED_DIR" && python3 -m tool.seed_session --client-id "$CLIENT_ID" \ + --client-secret "$(<"$SECRET_FILE")" --app todo-mcp) \ + || die "seed_session failed; rerun with -v or: tool/refresh_oauth_secret.sh --app todo-mcp" + [[ -f "$CRED_FILE" ]] || die "seed ran but $CRED_FILE is missing" + say 1/6 "seed: done" +} + +step_token() { + if has_hash pc && [[ -s "$CLIENT_TOKEN" ]]; then + say 2/6 "pc token: exists, skipped" + elif ((DRY_RUN)); then + say 2/6 "pc token: would mint, store 0600 at $CLIENT_TOKEN" + else + # A hash without the raw token is unusable; minting replaces the pc line. + umask 077 + mkdir -p "$(dirname "$CLIENT_TOKEN")" + "${ROOT}/mcp/mint_token.sh" pc >"$CLIENT_TOKEN" + chmod 600 "$CLIENT_TOKEN" + say 2/6 "pc token: minted" + fi + if grep -qxF "$ZSHENV_LINE" "$ZSHENV" 2>/dev/null; then + say 2/6 "zshenv: TODO_MCP_TOKEN line present" + elif ((DRY_RUN)); then + say 2/6 "zshenv: would append the TODO_MCP_TOKEN export" + else + printf '%s\n' "$ZSHENV_LINE" >>"$ZSHENV" + say 2/6 "zshenv: appended (new shells and sessions only)" + fi +} + +wait_listen() { + local _ + for _ in $(seq 1 60); do + if ss -ltnH "sport = :${PORT}" | grep -q .; then return 0; fi + sleep 1 + done + return 1 +} + +step_install() { + if ((DRY_RUN)); then + say 3/6 "install: would run mcp/install.sh, enable --now todo-mcp.service, wait for :${PORT}" + return + fi + say 3/6 "install: building (dart compile, can take a minute)" + "${ROOT}/mcp/install.sh" >/dev/null + systemctl --user enable --now todo-mcp.service 2>/dev/null + wait_listen || die "nothing listens on :${PORT}; see: journalctl --user -u todo-mcp -n 30" + say 3/6 "install: unit active, :${PORT} listening" +} + +step_publish() { + if ((DRY_RUN)); then + if [[ -x "$PUBLISH" || -f "$PUBLISH" ]]; then + say 4/6 "publish: would run publish_caddy_site.sh todo ${PORT}" + else + say 4/6 "publish: WOULD FAIL, $PUBLISH is missing" + fi + return + fi + [[ -f "$PUBLISH" ]] || die "missing $PUBLISH (untracked in testsAndMisc; restore it)" + bash "$PUBLISH" todo "$PORT" >/dev/null + say 4/6 "publish: https://todo.kuhy.duckdns.org -> 127.0.0.1:${PORT}" +} + +verify_one() { + local url="$1" wait="$2" + python3 "$VERIFY" "$url" --token-file "$CLIENT_TOKEN" --wait "$wait" + python3 "$VERIFY" "$url" --expect-401 --wait "$wait" +} + +step_verify() { + if ((DRY_RUN)); then + say 5/6 "verify: would initialize + list_tasks and expect 401 without a token on $LOCAL_URL and $PUBLIC_URL" + return + fi + [[ -s "$CLIENT_TOKEN" ]] || die "no client token at $CLIENT_TOKEN; run without --verify-only" + local out + out="$(verify_one "$LOCAL_URL" 20)" || die "local MCP check failed (journalctl --user -u todo-mcp -n 30)" + say 5/6 "verify local: ${out//$'\n'/, }" + # First public hit can wait on a fresh certificate (HTTP-01), so allow 60 s. + out="$(verify_one "$PUBLIC_URL" 60)" || die "public MCP check failed (docker logs gitea-caddy --tail 30)" + say 5/6 "verify public: ${out//$'\n'/, }" +} + +step_cloud() { + if has_hash cloud && ((! NEW_CLOUD)); then + say 6/6 "cloud token: exists (rerun with --new-cloud-token to rotate)" + return + fi + if ((DRY_RUN)); then + say 6/6 "cloud token: would mint, copy to clipboard (manual: paste as cloud secret TODO_MCP_TOKEN)" + return + fi + command -v xclip >/dev/null && [[ -n "${DISPLAY:-}" ]] || die "xclip or DISPLAY missing; cloud token not minted" + "${ROOT}/mcp/mint_token.sh" cloud | xclip -selection clipboard -in + say 6/6 "MANUAL: cloud token is on the clipboard; paste it as the cloud environment secret TODO_MCP_TOKEN" +} + +main() { + local arg + for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=1 ;; + --verify-only) VERIFY_ONLY=1 ;; + --new-cloud-token) NEW_CLOUD=1 ;; + *) die "unknown option: $arg" ;; + esac + done + if ((VERIFY_ONLY)); then step_verify; return; fi + step_seed + step_token + step_install + step_publish + step_verify + step_cloud +} + +main "$@" diff --git a/mcp/test_verify_mcp.py b/mcp/test_verify_mcp.py new file mode 100644 index 0000000..70d00b5 --- /dev/null +++ b/mcp/test_verify_mcp.py @@ -0,0 +1,53 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""verify_mcp.py against a fake Streamable HTTP server (no live state).""" + +from __future__ import annotations + +import json +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path + +import verify_mcp + + +class _Fake(BaseHTTPRequestHandler): + def do_POST(self) -> None: + body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + if self.headers.get("Authorization") != "Bearer good": + self.send_response(401) + self.end_headers() + return + self.send_response(200) + self.send_header("Mcp-Session-Id", "s1") + self.send_header("Content-Type", "text/event-stream") + self.end_headers() + if "id" in body: + reply = {"jsonrpc": "2.0", "id": body["id"], "result": {"content": []}} + self.wfile.write(f"event: message\ndata: {json.dumps(reply)}\n\n".encode()) + + def log_message(self, *_args: object) -> None: + return + + +def _serve() -> tuple[HTTPServer, str]: + server = HTTPServer(("127.0.0.1", 0), _Fake) + threading.Thread(target=server.serve_forever, daemon=True).start() + return server, f"http://127.0.0.1:{server.server_port}/mcp" + + +def test_good_token_passes_and_missing_token_is_401(tmp_path: Path) -> None: + server, url = _serve() + token = tmp_path / "t" + token.write_text("good\n") + assert verify_mcp.run([url, "--token-file", str(token)]) == 0 + assert verify_mcp.run([url, "--expect-401"]) == 0 + server.shutdown() + + +def test_bad_token_fails_and_expect_401_fails_when_open(tmp_path: Path) -> None: + server, url = _serve() + token = tmp_path / "t" + token.write_text("bad\n") + assert verify_mcp.run([url, "--token-file", str(token)]) == 1 + server.shutdown() diff --git a/mcp/verify_mcp.py b/mcp/verify_mcp.py new file mode 100644 index 0000000..5da1239 --- /dev/null +++ b/mcp/verify_mcp.py @@ -0,0 +1,142 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Live check of the todo MCP endpoint: real initialize + list_tasks, or a 401. + + verify_mcp.py --token-file F [--wait S] # expects a working session + verify_mcp.py --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:])) diff --git a/pubspec.yaml b/pubspec.yaml index cec52cd..96e6e24 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.51+1 +version: 1.0.52+1 environment: sdk: ^3.12.2