mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 18:07:03 +01:00
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
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:
parent
cf01784241
commit
d09978a338
163
mcp/go_live.sh
Executable file
163
mcp/go_live.sh
Executable 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
53
mcp/test_verify_mcp.py
Normal 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
142
mcp/verify_mcp.py
Normal 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:]))
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user