Add one-command MCP go-live + live verify
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
This commit is contained in:
Krzysztof Rudnicki 2026-11-02 10:45:45 +01:00
parent cf01784241
commit d09978a338
4 changed files with 359 additions and 1 deletions

163
mcp/go_live.sh Executable file
View File

@ -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 "$@"

53
mcp/test_verify_mcp.py Normal file
View File

@ -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()

142
mcp/verify_mcp.py Normal file
View File

@ -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 <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:]))

View File

@ -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