Add TickTick integration, MCP server, and dev project tracking
New integration with TickTick API for project/task management: - OAuth 2.0 auth flow (mcp-servers/ticktick/ticktick_auth.py) - MCP server with 9 tools for Claude Code (ticktick_mcp.py) - FastAPI service with SOPS vault credentials (api/services/ticktick_service.py) - JWT-protected REST router at /api/ticktick/ (api/routers/ticktick.py) - Credentials stored in SOPS vault (services/ticktick.sops.yaml) Dev project tracking (hybrid TickTick + DB): - New dev_projects table migration (14 columns, status index) - TickTick "Dev Projects" list for mobile visibility - First project seeded: TickTick Integration (linked both sides) Security: .tokens.json gitignored, token file permissions restricted, HTML-escaped OAuth callback, SOPS vault (not env vars) for secrets. Also: Installed Tailscale on ACG-5070 for office network access. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
313
mcp-servers/ticktick/ticktick_auth.py
Normal file
313
mcp-servers/ticktick/ticktick_auth.py
Normal file
@@ -0,0 +1,313 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
TickTick OAuth 2.0 Authentication Script
|
||||
|
||||
Performs the one-time OAuth flow to obtain access and refresh tokens from TickTick.
|
||||
Reads client credentials from the SOPS vault, opens a browser for user authorization,
|
||||
captures the callback on a local HTTP server, exchanges the code for tokens, and
|
||||
saves them to an encrypted local file.
|
||||
|
||||
Usage:
|
||||
python ticktick_auth.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import webbrowser
|
||||
from datetime import datetime, timezone
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode, urlparse, parse_qs
|
||||
from urllib.request import Request, urlopen
|
||||
from html import escape as html_escape
|
||||
from urllib.error import URLError, HTTPError
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
VAULT_SCRIPT = "D:/vault/scripts/vault.sh"
|
||||
VAULT_ENTRY = "services/ticktick.sops.yaml"
|
||||
|
||||
AUTH_URL = "https://ticktick.com/oauth/authorize"
|
||||
TOKEN_URL = "https://ticktick.com/oauth/token"
|
||||
REDIRECT_URI = "http://localhost:9876/callback"
|
||||
SCOPES = "tasks:read tasks:write"
|
||||
CALLBACK_PORT = 9876
|
||||
CALLBACK_TIMEOUT_SECONDS = 60
|
||||
|
||||
TOKEN_FILE = Path(__file__).resolve().parent / ".tokens.json"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vault credential retrieval
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def vault_get_field(field: str) -> str:
|
||||
"""Retrieve a single field from the SOPS vault entry."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["bash", VAULT_SCRIPT, "get-field", VAULT_ENTRY, field],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
print(f"[ERROR] Could not find bash or vault script at {VAULT_SCRIPT}")
|
||||
sys.exit(1)
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f"[ERROR] Vault command timed out while retrieving {field}")
|
||||
sys.exit(1)
|
||||
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.strip()
|
||||
print(f"[ERROR] Vault returned non-zero exit code for field '{field}'")
|
||||
if stderr:
|
||||
print(f" {stderr}")
|
||||
sys.exit(1)
|
||||
|
||||
value = result.stdout.strip()
|
||||
if not value:
|
||||
print(f"[ERROR] Vault returned empty value for field '{field}'")
|
||||
sys.exit(1)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Callback HTTP server
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _CallbackState:
|
||||
"""Shared mutable state between the HTTP handler and the main thread."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.authorization_code: str | None = None
|
||||
self.error: str | None = None
|
||||
self.received = threading.Event()
|
||||
|
||||
|
||||
class _CallbackHandler(BaseHTTPRequestHandler):
|
||||
"""Handles the OAuth redirect callback from TickTick."""
|
||||
|
||||
state: _CallbackState # set on the class before the server starts
|
||||
expected_csrf: str # set on the class before the server starts
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802 – required method name
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path != "/callback":
|
||||
self._respond(404, "Not found")
|
||||
return
|
||||
|
||||
params = parse_qs(parsed.query)
|
||||
|
||||
# Check for error response from provider
|
||||
if "error" in params:
|
||||
error_msg = params["error"][0]
|
||||
description = params.get("error_description", [""])[0]
|
||||
self.state.error = f"{error_msg}: {description}" if description else error_msg
|
||||
self._respond(400, f"Authorization failed: {self.state.error}")
|
||||
self.state.received.set()
|
||||
return
|
||||
|
||||
# Validate CSRF state parameter
|
||||
returned_state = params.get("state", [None])[0]
|
||||
if returned_state != self.expected_csrf:
|
||||
self.state.error = "CSRF state mismatch -- possible request forgery"
|
||||
self._respond(400, self.state.error)
|
||||
self.state.received.set()
|
||||
return
|
||||
|
||||
# Extract authorization code
|
||||
code = params.get("code", [None])[0]
|
||||
if not code:
|
||||
self.state.error = "No authorization code in callback"
|
||||
self._respond(400, self.state.error)
|
||||
self.state.received.set()
|
||||
return
|
||||
|
||||
self.state.authorization_code = code
|
||||
self._respond(
|
||||
200,
|
||||
"Authorization successful! You can close this tab and return to the terminal.",
|
||||
)
|
||||
self.state.received.set()
|
||||
|
||||
def _respond(self, status: int, body: str) -> None:
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.end_headers()
|
||||
html = (
|
||||
"<!DOCTYPE html><html><head><title>TickTick Auth</title></head>"
|
||||
f"<body><h2>{html_escape(body)}</h2></body></html>"
|
||||
)
|
||||
self.wfile.write(html.encode("utf-8"))
|
||||
|
||||
# Silence default request logging
|
||||
def log_message(self, format: str, *args: object) -> None: # noqa: A002
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token exchange
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def exchange_code_for_tokens(
|
||||
code: str,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
) -> dict:
|
||||
"""Exchange an authorization code for access and refresh tokens."""
|
||||
body = urlencode({
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"scope": SCOPES,
|
||||
}).encode("utf-8")
|
||||
|
||||
request = Request(
|
||||
TOKEN_URL,
|
||||
data=body,
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
with urlopen(request, timeout=15) as response:
|
||||
data = json.loads(response.read().decode("utf-8"))
|
||||
except HTTPError as exc:
|
||||
error_body = exc.read().decode("utf-8", errors="replace")
|
||||
print(f"[ERROR] Token exchange failed (HTTP {exc.code})")
|
||||
print(f" Response: {error_body}")
|
||||
sys.exit(1)
|
||||
except URLError as exc:
|
||||
print(f"[ERROR] Could not reach token endpoint: {exc.reason}")
|
||||
sys.exit(1)
|
||||
except json.JSONDecodeError:
|
||||
print("[ERROR] Token endpoint returned invalid JSON")
|
||||
sys.exit(1)
|
||||
|
||||
if "access_token" not in data:
|
||||
print("[ERROR] Token response missing 'access_token'")
|
||||
print(f" Full response: {json.dumps(data, indent=2)}")
|
||||
sys.exit(1)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token persistence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def save_tokens(token_data: dict) -> None:
|
||||
"""Persist tokens to a local JSON file."""
|
||||
payload = {
|
||||
"access_token": token_data["access_token"],
|
||||
"refresh_token": token_data.get("refresh_token", ""),
|
||||
"token_type": token_data.get("token_type", "bearer"),
|
||||
"obtained_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
TOKEN_FILE.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||
# Restrict file permissions (owner read/write only)
|
||||
try:
|
||||
TOKEN_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
||||
except OSError:
|
||||
pass # Windows may not support POSIX permissions
|
||||
print(f"[OK] Tokens saved to {TOKEN_FILE}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main flow
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
print("[INFO] TickTick OAuth 2.0 Authentication")
|
||||
print("=" * 50)
|
||||
|
||||
# -- 1. Read credentials from SOPS vault ----------------------------------
|
||||
print("[INFO] Reading credentials from SOPS vault ...")
|
||||
client_id = vault_get_field("credentials.client_id")
|
||||
client_secret = vault_get_field("credentials.client_secret")
|
||||
print(f"[OK] Client ID retrieved (ends ...{client_id[-4:]})")
|
||||
|
||||
# -- 2. Prepare CSRF state and authorization URL --------------------------
|
||||
csrf_state = secrets.token_urlsafe(32)
|
||||
|
||||
auth_params = urlencode({
|
||||
"client_id": client_id,
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
"response_type": "code",
|
||||
"scope": SCOPES,
|
||||
"state": csrf_state,
|
||||
})
|
||||
full_auth_url = f"{AUTH_URL}?{auth_params}"
|
||||
|
||||
# -- 3. Start local callback server ---------------------------------------
|
||||
callback_state = _CallbackState()
|
||||
_CallbackHandler.state = callback_state
|
||||
_CallbackHandler.expected_csrf = csrf_state
|
||||
|
||||
try:
|
||||
server = HTTPServer(("127.0.0.1", CALLBACK_PORT), _CallbackHandler)
|
||||
except OSError as exc:
|
||||
print(f"[ERROR] Could not start callback server on port {CALLBACK_PORT}: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
server_thread.start()
|
||||
print(f"[OK] Callback server listening on http://127.0.0.1:{CALLBACK_PORT}/callback")
|
||||
|
||||
# -- 4. Open browser for authorization ------------------------------------
|
||||
print("[INFO] Opening browser for TickTick authorization ...")
|
||||
print(f"[INFO] If the browser does not open, visit this URL manually:")
|
||||
print(f" {full_auth_url}")
|
||||
webbrowser.open(full_auth_url)
|
||||
|
||||
# -- 5. Wait for callback -------------------------------------------------
|
||||
print(f"[INFO] Waiting up to {CALLBACK_TIMEOUT_SECONDS}s for authorization callback ...")
|
||||
received = callback_state.received.wait(timeout=CALLBACK_TIMEOUT_SECONDS)
|
||||
server.shutdown()
|
||||
|
||||
if not received:
|
||||
print(f"[ERROR] Timed out after {CALLBACK_TIMEOUT_SECONDS}s waiting for callback")
|
||||
print(" Make sure you completed the authorization in your browser.")
|
||||
sys.exit(1)
|
||||
|
||||
if callback_state.error:
|
||||
print(f"[ERROR] Authorization failed: {callback_state.error}")
|
||||
sys.exit(1)
|
||||
|
||||
code = callback_state.authorization_code
|
||||
if not code:
|
||||
print("[ERROR] No authorization code received (unknown error)")
|
||||
sys.exit(1)
|
||||
|
||||
print("[OK] Authorization code received")
|
||||
|
||||
# -- 6. Exchange code for tokens ------------------------------------------
|
||||
print("[INFO] Exchanging authorization code for tokens ...")
|
||||
token_data = exchange_code_for_tokens(code, client_id, client_secret)
|
||||
print("[OK] Token exchange successful")
|
||||
|
||||
# -- 7. Save tokens -------------------------------------------------------
|
||||
save_tokens(token_data)
|
||||
|
||||
print("=" * 50)
|
||||
print("[OK] TickTick authentication complete")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user