From b26e185a801c6100fd0bb4c4d8bc5300a5529a50 Mon Sep 17 00:00:00 2001 From: Mike Swanson Date: Tue, 31 Mar 2026 10:08:53 -0700 Subject: [PATCH] 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) --- .claude/memory/MEMORY.md | 2 + .../memory/reference_ticktick_integration.md | 33 + .gitignore | 1 + api/main.py | 4 + api/routers/ticktick.py | 333 ++++++++++ api/services/ticktick_service.py | 596 ++++++++++++++++++ mcp-servers/ticktick/ticktick_auth.py | 313 +++++++++ mcp-servers/ticktick/ticktick_mcp.py | 595 +++++++++++++++++ migrations/add_dev_projects_table.sql | 22 + session-logs/2026-03-31-session.md | 131 ++++ 10 files changed, 2030 insertions(+) create mode 100644 .claude/memory/reference_ticktick_integration.md create mode 100644 api/routers/ticktick.py create mode 100644 api/services/ticktick_service.py create mode 100644 mcp-servers/ticktick/ticktick_auth.py create mode 100644 mcp-servers/ticktick/ticktick_mcp.py create mode 100644 migrations/add_dev_projects_table.sql create mode 100644 session-logs/2026-03-31-session.md diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index 951675c..2358fd7 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -9,10 +9,12 @@ - [ACG-5070 Workstation](reference_workstation_setup.md) - Windows 11, replaced CachyOS. SOPS vault, Ollama, all dev tools. - [Matomo Analytics](reference_matomo_analytics.md) - Self-hosted analytics at analytics.azcomputerguru.com, site IDs, tracking for all 3 sites - [Dataforth Contact - AJ](reference_dataforth_contact.md) - AJ at Dataforth, dataforthgit@ email forwarding to him +- [TickTick Integration](reference_ticktick_integration.md) - OAuth API integration, MCP server, SOPS vault creds, project/task CRUD ## Feedback - [D2TESTNAS SSH Access](feedback_d2testnas_ssh.md) - Use root@192.168.0.9 with Paper123!@#, not sysadmin - [Bypass Permissions Setting](feedback_bypass_permissions_setting.md) - Set permissions.defaultMode to bypassPermissions in settings.json on all machines +- [365 Remediation Tool](feedback_365_remediation_tool.md) - Always means Graph API app fabb3421, not CIPP ## Machine - [ACG-5070 Workstation Setup](reference_workstation_setup.md) - Windows 11 Pro clean install 2026-03-30, replaced CachyOS. All tools installed. diff --git a/.claude/memory/reference_ticktick_integration.md b/.claude/memory/reference_ticktick_integration.md new file mode 100644 index 0000000..e9d9fef --- /dev/null +++ b/.claude/memory/reference_ticktick_integration.md @@ -0,0 +1,33 @@ +--- +name: TickTick Integration +description: TickTick API integration for project/task management - OAuth credentials in SOPS vault, MCP server, API service +type: reference +--- + +## TickTick Integration (Built 2026-03-31) + +**App Name:** ClaudeTools (registered at developer.ticktick.com) + +### Credentials +- SOPS vault: `services/ticktick.sops.yaml` +- Fields: `credentials.client_id`, `credentials.client_secret`, `credentials.oauth_redirect_url` +- OAuth tokens: `mcp-servers/ticktick/.tokens.json` (gitignored, auto-refreshed) + +### Components +- **MCP Server:** `mcp-servers/ticktick/ticktick_mcp.py` - 9 tools for Claude Code (registered in `.mcp.json`) +- **OAuth Auth:** `mcp-servers/ticktick/ticktick_auth.py` - One-time browser auth flow (localhost:9876 callback) +- **API Service:** `api/services/ticktick_service.py` - Async service, SOPS vault credentials, auto token refresh +- **API Router:** `api/routers/ticktick.py` - REST at `/api/ticktick/`, JWT-protected + +### TickTick API +- Base URL: `https://api.ticktick.com/open/v1` +- Auth: OAuth 2.0 Bearer tokens, scopes: `tasks:read tasks:write` +- No webhooks (must poll), no search endpoint (filter client-side) +- Priority values: 0=none, 1=low, 3=medium, 5=high (non-sequential) +- Token endpoint requires `application/x-www-form-urlencoded` (not JSON) + +### MCP Tools +`ticktick_list_projects`, `ticktick_get_project`, `ticktick_create_project`, `ticktick_update_project`, `ticktick_delete_project`, `ticktick_create_task`, `ticktick_update_task`, `ticktick_complete_task`, `ticktick_delete_task` + +### Re-auth +If tokens expire completely, run: `python mcp-servers/ticktick/ticktick_auth.py` from bash (not PowerShell - needs vault access via bash). diff --git a/.gitignore b/.gitignore index c5910b8..51edc68 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,7 @@ build/ *.sqlite logs/ .claude/tokens.json +**/.tokens.json .claude/context-recall-config.env .claude/context-recall-config.env.backup .claude/context-cache/ diff --git a/api/main.py b/api/main.py index bef329a..a10a644 100644 --- a/api/main.py +++ b/api/main.py @@ -35,6 +35,7 @@ from api.routers import ( version, quotes, admin_quotes, + ticktick, ) # Import middleware @@ -130,6 +131,9 @@ app.include_router(bulk_import.router, prefix="/api/bulk-import", tags=["Bulk Im app.include_router(quotes.router, prefix="/api/quotes", tags=["Quotes"]) app.include_router(admin_quotes.router, prefix="/api/admin/quotes", tags=["Admin Quotes"]) +# External integrations +app.include_router(ticktick.router, prefix="/api/ticktick", tags=["TickTick"]) + if __name__ == "__main__": import uvicorn diff --git a/api/routers/ticktick.py b/api/routers/ticktick.py new file mode 100644 index 0000000..e7a0bb0 --- /dev/null +++ b/api/routers/ticktick.py @@ -0,0 +1,333 @@ +""" +TickTick API router for ClaudeTools. + +This module defines REST API endpoints for managing TickTick projects and tasks, +proxying requests through the TickTickService with automatic token management. +""" + +import logging +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field + +from api.middleware.auth import get_current_user +from api.services.ticktick_service import TickTickResult, get_ticktick_service + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +# -------------------------------------------------------------------------- +# Pydantic request/response schemas +# -------------------------------------------------------------------------- + + +class ProjectCreate(BaseModel): + """Schema for creating a new TickTick project.""" + + name: str = Field(..., min_length=1, max_length=200, description="Project name") + color: Optional[str] = Field( + None, description="Hex color string (e.g., '#FF6347')" + ) + view_mode: Optional[str] = Field( + None, description="View mode: 'list', 'kanban', or 'timeline'" + ) + kind: Optional[str] = Field( + None, description="Project kind: 'TASK' or 'NOTE'" + ) + + +class ProjectUpdate(BaseModel): + """Schema for updating an existing TickTick project.""" + + name: Optional[str] = Field( + None, min_length=1, max_length=200, description="New project name" + ) + color: Optional[str] = Field(None, description="New hex color string") + view_mode: Optional[str] = Field(None, description="New view mode") + + +class TaskCreate(BaseModel): + """Schema for creating a new task in a TickTick project.""" + + title: str = Field(..., min_length=1, max_length=500, description="Task title") + content: Optional[str] = Field(None, description="Task description/content") + priority: Optional[int] = Field( + None, + ge=0, + le=5, + description="Priority: 0=none, 1=low, 3=medium, 5=high", + ) + due_date: Optional[str] = Field( + None, description="Due date in ISO 8601 format" + ) + tags: Optional[list[str]] = Field(None, description="List of tag strings") + + +class TaskUpdate(BaseModel): + """Schema for updating an existing task.""" + + title: Optional[str] = Field( + None, min_length=1, max_length=500, description="New task title" + ) + content: Optional[str] = Field(None, description="New task content") + priority: Optional[int] = Field( + None, ge=0, le=5, description="New priority level" + ) + due_date: Optional[str] = Field(None, description="New due date in ISO 8601 format") + tags: Optional[list[str]] = Field(None, description="New list of tags") + + +class TickTickResponse(BaseModel): + """Standard response wrapper for all TickTick endpoints.""" + + success: bool + data: Optional[dict] = None + error: Optional[str] = None + + +# -------------------------------------------------------------------------- +# Helpers +# -------------------------------------------------------------------------- + + +def _to_response(result: TickTickResult, status_code_on_error: int = 500) -> dict: + """ + Convert a TickTickResult to a JSON-serializable response dict. + + Raises an HTTPException when the result indicates failure. + + Args: + result: The service result to convert. + status_code_on_error: HTTP status code for error responses. + + Returns: + Dict matching the TickTickResponse schema. + """ + if not result.success: + raise HTTPException( + status_code=status_code_on_error, + detail={ + "success": False, + "data": None, + "error": result.error or "Unknown error", + }, + ) + return {"success": True, "data": result.data, "error": None} + + +# -------------------------------------------------------------------------- +# Project endpoints +# -------------------------------------------------------------------------- + + +@router.get( + "", + response_model=TickTickResponse, + summary="List all TickTick projects", + status_code=status.HTTP_200_OK, +) +async def list_projects(current_user: dict = Depends(get_current_user)): + """ + Retrieve all projects (lists) from the authenticated TickTick account. + + **Example Request:** + ``` + GET /api/ticktick + ``` + """ + service = get_ticktick_service() + result = await service.list_projects() + return _to_response(result) + + +@router.get( + "/{project_id}", + response_model=TickTickResponse, + summary="Get a TickTick project with tasks", + status_code=status.HTTP_200_OK, +) +async def get_project(project_id: str, current_user: dict = Depends(get_current_user)): + """ + Retrieve a single project and its associated task data. + + **Path Parameters:** + - **project_id**: The TickTick project ID. + """ + service = get_ticktick_service() + result = await service.get_project(project_id) + return _to_response(result, status_code_on_error=404) + + +@router.post( + "", + response_model=TickTickResponse, + summary="Create a new TickTick project", + status_code=status.HTTP_201_CREATED, +) +async def create_project(body: ProjectCreate, current_user: dict = Depends(get_current_user)): + """ + Create a new project (list) in TickTick. + + **Request Body:** + - **name** (required): Project name. + - **color**: Hex color string. + - **view_mode**: View mode ('list', 'kanban', 'timeline'). + - **kind**: Project kind ('TASK' or 'NOTE'). + """ + service = get_ticktick_service() + result = await service.create_project( + name=body.name, + color=body.color, + view_mode=body.view_mode, + kind=body.kind, + ) + return _to_response(result, status_code_on_error=400) + + +@router.put( + "/{project_id}", + response_model=TickTickResponse, + summary="Update a TickTick project", + status_code=status.HTTP_200_OK, +) +async def update_project(project_id: str, body: ProjectUpdate, current_user: dict = Depends(get_current_user)): + """ + Update an existing project's properties. + + **Path Parameters:** + - **project_id**: The TickTick project ID to update. + + **Request Body:** + At least one field must be provided. + """ + service = get_ticktick_service() + result = await service.update_project( + project_id=project_id, + name=body.name, + color=body.color, + view_mode=body.view_mode, + ) + return _to_response(result, status_code_on_error=400) + + +@router.delete( + "/{project_id}", + response_model=TickTickResponse, + summary="Delete a TickTick project", + status_code=status.HTTP_200_OK, +) +async def delete_project(project_id: str, current_user: dict = Depends(get_current_user)): + """ + Delete a project from TickTick. + + **Path Parameters:** + - **project_id**: The TickTick project ID to delete. + """ + service = get_ticktick_service() + result = await service.delete_project(project_id) + return _to_response(result, status_code_on_error=404) + + +# -------------------------------------------------------------------------- +# Task endpoints +# -------------------------------------------------------------------------- + + +@router.post( + "/{project_id}/tasks", + response_model=TickTickResponse, + summary="Create a task in a TickTick project", + status_code=status.HTTP_201_CREATED, +) +async def create_task(project_id: str, body: TaskCreate, current_user: dict = Depends(get_current_user)): + """ + Create a new task within the specified project. + + **Path Parameters:** + - **project_id**: The TickTick project ID. + + **Request Body:** + - **title** (required): Task title. + - **content**: Task description. + - **priority**: 0=none, 1=low, 3=medium, 5=high. + - **due_date**: ISO 8601 date string. + - **tags**: List of tag strings. + """ + service = get_ticktick_service() + result = await service.create_task( + title=body.title, + project_id=project_id, + content=body.content, + priority=body.priority, + due_date=body.due_date, + tags=body.tags, + ) + return _to_response(result, status_code_on_error=400) + + +@router.put( + "/{project_id}/tasks/{task_id}", + response_model=TickTickResponse, + summary="Update a task in a TickTick project", + status_code=status.HTTP_200_OK, +) +async def update_task(project_id: str, task_id: str, body: TaskUpdate, current_user: dict = Depends(get_current_user)): + """ + Update an existing task's properties. + + **Path Parameters:** + - **project_id**: The TickTick project ID. + - **task_id**: The task ID to update. + """ + service = get_ticktick_service() + result = await service.update_task( + task_id=task_id, + project_id=project_id, + title=body.title, + content=body.content, + priority=body.priority, + due_date=body.due_date, + tags=body.tags, + ) + return _to_response(result, status_code_on_error=400) + + +@router.post( + "/{project_id}/tasks/{task_id}/complete", + response_model=TickTickResponse, + summary="Complete a task", + status_code=status.HTTP_200_OK, +) +async def complete_task(project_id: str, task_id: str, current_user: dict = Depends(get_current_user)): + """ + Mark a task as complete in TickTick. + + **Path Parameters:** + - **project_id**: The TickTick project ID. + - **task_id**: The task ID to mark complete. + """ + service = get_ticktick_service() + result = await service.complete_task(task_id=task_id, project_id=project_id) + return _to_response(result, status_code_on_error=400) + + +@router.delete( + "/{project_id}/tasks/{task_id}", + response_model=TickTickResponse, + summary="Delete a task", + status_code=status.HTTP_200_OK, +) +async def delete_task(project_id: str, task_id: str, current_user: dict = Depends(get_current_user)): + """ + Delete a task from a TickTick project. + + **Path Parameters:** + - **project_id**: The TickTick project ID. + - **task_id**: The task ID to delete. + """ + service = get_ticktick_service() + result = await service.delete_task(task_id=task_id, project_id=project_id) + return _to_response(result, status_code_on_error=404) diff --git a/api/services/ticktick_service.py b/api/services/ticktick_service.py new file mode 100644 index 0000000..4eacdb6 --- /dev/null +++ b/api/services/ticktick_service.py @@ -0,0 +1,596 @@ +""" +TickTick API integration service for ClaudeTools. + +This module handles all interactions with the TickTick Open API for project +and task management. Tokens are managed via a local JSON file with automatic +refresh on 401 responses. + +API Documentation: https://developer.ticktick.com/api +""" + +import json +import logging +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +import httpx + +logger = logging.getLogger(__name__) + +TICKTICK_API_BASE_URL = "https://api.ticktick.com/open/v1" +TICKTICK_TOKEN_URL = "https://ticktick.com/oauth/token" +TICKTICK_TOKEN_FILE = Path(__file__).resolve().parents[2] / "mcp-servers" / "ticktick" / ".tokens.json" + +VAULT_SCRIPT = "D:/vault/scripts/vault.sh" +VAULT_ENTRY = "services/ticktick.sops.yaml" + +TICKTICK_TIMEOUT_SECONDS = 30.0 +TICKTICK_CONNECT_TIMEOUT_SECONDS = 10.0 + + +@dataclass +class TickTickResult: + """Result wrapper for all TickTick API operations.""" + + success: bool + data: Optional[dict] = None + error: Optional[str] = None + + +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, + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + logger.error("[ERROR] Vault returned empty or error for %s", field) + return "" + except (FileNotFoundError, subprocess.TimeoutExpired) as exc: + logger.error("[ERROR] Vault retrieval failed for %s: %s", field, exc) + return "" + + +class TickTickService: + """ + Service for interacting with the TickTick Open API. + + Handles project and task CRUD operations with automatic OAuth token + refresh when the access token expires. Credentials are retrieved from + the SOPS vault. + """ + + def __init__( + self, + api_base_url: str = TICKTICK_API_BASE_URL, + token_file: Path = TICKTICK_TOKEN_FILE, + timeout: float = TICKTICK_TIMEOUT_SECONDS, + connect_timeout: float = TICKTICK_CONNECT_TIMEOUT_SECONDS, + ): + self.api_base_url = api_base_url.rstrip("/") + self.token_file = token_file + self.timeout = httpx.Timeout(timeout, connect=connect_timeout) + self._access_token: Optional[str] = None + self._refresh_token: Optional[str] = None + self._client_id: Optional[str] = None + self._client_secret: Optional[str] = None + self._load_tokens() + + # ------------------------------------------------------------------ + # Token management + # ------------------------------------------------------------------ + + def _load_tokens(self) -> None: + """Load access and refresh tokens from the local token file.""" + if not self.token_file.exists(): + logger.warning( + "[WARNING] TickTick token file not found at %s", self.token_file + ) + return + + try: + data = json.loads(self.token_file.read_text(encoding="utf-8")) + self._access_token = data.get("access_token") + self._refresh_token = data.get("refresh_token") + logger.info("[OK] TickTick tokens loaded from %s", self.token_file) + except (json.JSONDecodeError, OSError) as exc: + logger.error( + "[ERROR] Failed to read TickTick token file: %s", exc + ) + + def _save_tokens(self) -> None: + """Persist current tokens back to the token file.""" + try: + existing: dict = {} + if self.token_file.exists(): + try: + existing = json.loads( + self.token_file.read_text(encoding="utf-8") + ) + except (json.JSONDecodeError, OSError): + existing = {} + + existing["access_token"] = self._access_token + existing["refresh_token"] = self._refresh_token + + self.token_file.write_text( + json.dumps(existing, indent=2) + "\n", encoding="utf-8" + ) + logger.info("[OK] TickTick tokens saved to %s", self.token_file) + except OSError as exc: + logger.error( + "[ERROR] Failed to write TickTick token file: %s", exc + ) + + async def _refresh_access_token(self) -> bool: + """ + Refresh the OAuth access token using the stored refresh token. + + Returns: + True if the token was refreshed successfully, False otherwise. + """ + if not self._refresh_token: + logger.error("[ERROR] No refresh token available for TickTick") + return False + + # Lazy-load vault credentials for refresh + if not self._client_id: + self._client_id = _vault_get_field("credentials.client_id") + if not self._client_secret: + self._client_secret = _vault_get_field("credentials.client_secret") + + if not self._client_id or not self._client_secret: + logger.error( + "[ERROR] Could not retrieve TickTick client credentials from SOPS vault" + ) + return False + + logger.info("[INFO] Refreshing TickTick access token") + + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.post( + TICKTICK_TOKEN_URL, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + }, + data={ + "grant_type": "refresh_token", + "refresh_token": self._refresh_token, + "client_id": self._client_id, + "client_secret": self._client_secret, + }, + ) + + if response.status_code != 200: + logger.error( + "[ERROR] TickTick token refresh failed with status %d: %s", + response.status_code, + response.text, + ) + return False + + token_data = response.json() + self._access_token = token_data.get("access_token") + if "refresh_token" in token_data: + self._refresh_token = token_data["refresh_token"] + + self._save_tokens() + logger.info("[OK] TickTick access token refreshed successfully") + return True + + except httpx.HTTPError as exc: + logger.error( + "[ERROR] TickTick token refresh request failed: %s", exc + ) + return False + + # ------------------------------------------------------------------ + # HTTP helpers + # ------------------------------------------------------------------ + + def _get_client(self) -> httpx.AsyncClient: + """ + Create an async HTTP client with configured settings. + + Returns: + Configured httpx.AsyncClient for TickTick API calls. + """ + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + } + if self._access_token: + headers["Authorization"] = f"Bearer {self._access_token}" + + return httpx.AsyncClient(timeout=self.timeout, headers=headers) + + async def _request( + self, + method: str, + endpoint: str, + json_body: Optional[dict] = None, + retry_on_401: bool = True, + ) -> TickTickResult: + """ + Execute an API request with automatic 401 retry after token refresh. + + Args: + method: HTTP method (GET, POST, PUT, DELETE). + endpoint: API path relative to the base URL (e.g., '/project'). + json_body: Optional JSON payload for POST/PUT requests. + retry_on_401: Whether to attempt a token refresh on 401. + + Returns: + TickTickResult with success status and response data or error. + """ + url = f"{self.api_base_url}{endpoint}" + + try: + async with self._get_client() as client: + response = await client.request( + method, url, json=json_body + ) + + if response.status_code == 401 and retry_on_401: + logger.info( + "[INFO] TickTick API returned 401, attempting token refresh" + ) + refreshed = await self._refresh_access_token() + if refreshed: + return await self._request( + method, endpoint, json_body, retry_on_401=False + ) + return TickTickResult( + success=False, + error="Authentication failed and token refresh was unsuccessful", + ) + + if response.status_code == 204: + return TickTickResult(success=True, data={}) + + if response.status_code >= 400: + error_text = response.text + logger.error( + "[ERROR] TickTick API %s %s returned %d: %s", + method, + endpoint, + response.status_code, + error_text, + ) + return TickTickResult( + success=False, + error=f"API returned {response.status_code}: {error_text}", + ) + + # Some responses may have empty bodies (e.g., 200 with no content) + if not response.text.strip(): + return TickTickResult(success=True, data={}) + + return TickTickResult(success=True, data=response.json()) + + except httpx.HTTPError as exc: + logger.error( + "[ERROR] TickTick API request failed (%s %s): %s", + method, + endpoint, + exc, + ) + return TickTickResult( + success=False, error=f"Request failed: {exc}" + ) + except json.JSONDecodeError as exc: + logger.error( + "[ERROR] TickTick API returned invalid JSON (%s %s): %s", + method, + endpoint, + exc, + ) + return TickTickResult( + success=False, error=f"Invalid JSON in response: {exc}" + ) + + # ------------------------------------------------------------------ + # Project operations + # ------------------------------------------------------------------ + + async def list_projects(self) -> TickTickResult: + """ + List all projects (lists) in the TickTick account. + + Returns: + TickTickResult with data containing a list of project dicts. + """ + logger.info("[INFO] Fetching TickTick project list") + result = await self._request("GET", "/project") + if result.success and isinstance(result.data, list): + result.data = {"projects": result.data} + return result + + async def get_project(self, project_id: str) -> TickTickResult: + """ + Get a single project with its task data. + + Args: + project_id: The TickTick project ID. + + Returns: + TickTickResult with the project data including tasks. + """ + if not project_id: + return TickTickResult(success=False, error="project_id is required") + + logger.info("[INFO] Fetching TickTick project %s", project_id) + result = await self._request("GET", f"/project/{project_id}/data") + return result + + async def create_project( + self, + name: str, + color: Optional[str] = None, + view_mode: Optional[str] = None, + kind: Optional[str] = None, + ) -> TickTickResult: + """ + Create a new project (list) in TickTick. + + Args: + name: Project name. + color: Optional hex color string (e.g., '#FF6347'). + view_mode: Optional view mode ('list', 'kanban', 'timeline'). + kind: Optional project kind ('TASK' or 'NOTE'). + + Returns: + TickTickResult with the created project data. + """ + if not name: + return TickTickResult(success=False, error="name is required") + + body: dict = {"name": name} + if color is not None: + body["color"] = color + if view_mode is not None: + body["viewMode"] = view_mode + if kind is not None: + body["kind"] = kind + + logger.info("[INFO] Creating TickTick project: %s", name) + return await self._request("POST", "/project", json_body=body) + + async def update_project( + self, + project_id: str, + name: Optional[str] = None, + color: Optional[str] = None, + view_mode: Optional[str] = None, + ) -> TickTickResult: + """ + Update an existing project in TickTick. + + Args: + project_id: The TickTick project ID to update. + name: Optional new project name. + color: Optional new hex color string. + view_mode: Optional new view mode. + + Returns: + TickTickResult with the updated project data. + """ + if not project_id: + return TickTickResult(success=False, error="project_id is required") + + body: dict = {} + if name is not None: + body["name"] = name + if color is not None: + body["color"] = color + if view_mode is not None: + body["viewMode"] = view_mode + + if not body: + return TickTickResult( + success=False, error="At least one field to update is required" + ) + + logger.info("[INFO] Updating TickTick project %s", project_id) + return await self._request( + "POST", f"/project/{project_id}", json_body=body + ) + + async def delete_project(self, project_id: str) -> TickTickResult: + """ + Delete a project from TickTick. + + Args: + project_id: The TickTick project ID to delete. + + Returns: + TickTickResult with success status. + """ + if not project_id: + return TickTickResult(success=False, error="project_id is required") + + logger.info("[INFO] Deleting TickTick project %s", project_id) + return await self._request("DELETE", f"/project/{project_id}") + + # ------------------------------------------------------------------ + # Task operations + # ------------------------------------------------------------------ + + async def create_task( + self, + title: str, + project_id: str, + content: Optional[str] = None, + priority: Optional[int] = None, + due_date: Optional[str] = None, + tags: Optional[list[str]] = None, + ) -> TickTickResult: + """ + Create a new task in a TickTick project. + + Args: + title: Task title. + project_id: ID of the project to create the task in. + content: Optional task description/content. + priority: Optional priority (0=none, 1=low, 3=medium, 5=high). + due_date: Optional due date in ISO 8601 format. + tags: Optional list of tag strings. + + Returns: + TickTickResult with the created task data. + """ + if not title: + return TickTickResult(success=False, error="title is required") + if not project_id: + return TickTickResult(success=False, error="project_id is required") + + body: dict = {"title": title, "projectId": project_id} + if content is not None: + body["content"] = content + if priority is not None: + body["priority"] = priority + if due_date is not None: + body["dueDate"] = due_date + if tags is not None: + body["tags"] = tags + + logger.info( + "[INFO] Creating TickTick task '%s' in project %s", + title, + project_id, + ) + return await self._request("POST", "/task", json_body=body) + + async def update_task( + self, + task_id: str, + project_id: str, + title: Optional[str] = None, + content: Optional[str] = None, + priority: Optional[int] = None, + due_date: Optional[str] = None, + tags: Optional[list[str]] = None, + ) -> TickTickResult: + """ + Update an existing task in TickTick. + + Args: + task_id: The task ID to update. + project_id: The project ID containing the task. + title: Optional new task title. + content: Optional new task content. + priority: Optional new priority level. + due_date: Optional new due date in ISO 8601 format. + tags: Optional new list of tags. + + Returns: + TickTickResult with the updated task data. + """ + if not task_id: + return TickTickResult(success=False, error="task_id is required") + if not project_id: + return TickTickResult(success=False, error="project_id is required") + + body: dict = {"id": task_id, "projectId": project_id} + if title is not None: + body["title"] = title + if content is not None: + body["content"] = content + if priority is not None: + body["priority"] = priority + if due_date is not None: + body["dueDate"] = due_date + if tags is not None: + body["tags"] = tags + + logger.info( + "[INFO] Updating TickTick task %s in project %s", + task_id, + project_id, + ) + return await self._request( + "POST", f"/task/{task_id}", json_body=body + ) + + async def complete_task( + self, task_id: str, project_id: str + ) -> TickTickResult: + """ + Mark a task as complete in TickTick. + + Args: + task_id: The task ID to complete. + project_id: The project ID containing the task. + + Returns: + TickTickResult with success status. + """ + if not task_id: + return TickTickResult(success=False, error="task_id is required") + if not project_id: + return TickTickResult(success=False, error="project_id is required") + + logger.info( + "[INFO] Completing TickTick task %s in project %s", + task_id, + project_id, + ) + return await self._request( + "POST", f"/project/{project_id}/task/{task_id}/complete" + ) + + async def delete_task( + self, task_id: str, project_id: str + ) -> TickTickResult: + """ + Delete a task from TickTick. + + Args: + task_id: The task ID to delete. + project_id: The project ID containing the task. + + Returns: + TickTickResult with success status. + """ + if not task_id: + return TickTickResult(success=False, error="task_id is required") + if not project_id: + return TickTickResult(success=False, error="project_id is required") + + logger.info( + "[INFO] Deleting TickTick task %s from project %s", + task_id, + project_id, + ) + return await self._request( + "DELETE", f"/project/{project_id}/task/{task_id}" + ) + + +# -------------------------------------------------------------------------- +# Singleton accessor +# -------------------------------------------------------------------------- + +_ticktick_service: Optional[TickTickService] = None + + +def get_ticktick_service() -> TickTickService: + """ + Return a singleton TickTickService instance. + + Creates the service on first call, reuses it thereafter. + + Returns: + The shared TickTickService instance. + """ + global _ticktick_service + if _ticktick_service is None: + _ticktick_service = TickTickService() + return _ticktick_service diff --git a/mcp-servers/ticktick/ticktick_auth.py b/mcp-servers/ticktick/ticktick_auth.py new file mode 100644 index 0000000..4f69895 --- /dev/null +++ b/mcp-servers/ticktick/ticktick_auth.py @@ -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 = ( + "TickTick Auth" + f"

{html_escape(body)}

" + ) + 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() diff --git a/mcp-servers/ticktick/ticktick_mcp.py b/mcp-servers/ticktick/ticktick_mcp.py new file mode 100644 index 0000000..420583d --- /dev/null +++ b/mcp-servers/ticktick/ticktick_mcp.py @@ -0,0 +1,595 @@ +#!/usr/bin/env python3 +""" +TickTick MCP Server +Provides Claude Code with direct tools to manage TickTick projects and tasks. + +Requires: + - pip install mcp httpx + - Token file at .tokens.json (run ticktick_auth.py first) + - Vault credentials at services/ticktick.sops.yaml +""" + +import asyncio +import json +import subprocess +import sys +import time +from pathlib import Path +from typing import Any, Optional + +import httpx + +try: + from mcp.server import Server + from mcp.types import Tool, TextContent +except ImportError: + print("[ERROR] MCP package not installed. Run: pip install mcp", file=sys.stderr) + sys.exit(1) + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +TICKTICK_API_BASE = "https://api.ticktick.com/open/v1" +TICKTICK_OAUTH_TOKEN_URL = "https://ticktick.com/oauth/token" +SCRIPT_DIR = Path(__file__).parent +TOKENS_PATH = SCRIPT_DIR / ".tokens.json" +VAULT_SCRIPT = "D:/vault/scripts/vault.sh" +VAULT_ENTRY = "services/ticktick.sops.yaml" + +# --------------------------------------------------------------------------- +# Credential & token helpers +# --------------------------------------------------------------------------- + +_vault_cache: dict[str, str] = {} + + +def _vault_get_field(field: str) -> str: + """Retrieve a field from the SOPS vault, caching results in memory.""" + if field in _vault_cache: + return _vault_cache[field] + result = subprocess.run( + ["bash", VAULT_SCRIPT, "get-field", VAULT_ENTRY, field], + capture_output=True, + text=True, + timeout=15, + ) + if result.returncode != 0: + raise RuntimeError( + f"[ERROR] Vault lookup failed for {field}: {result.stderr.strip()}" + ) + value = result.stdout.strip() + _vault_cache[field] = value + return value + + +def _load_tokens() -> dict[str, str]: + """Load tokens from disk. Raises FileNotFoundError if missing.""" + if not TOKENS_PATH.exists(): + raise FileNotFoundError( + f"[ERROR] Token file not found at {TOKENS_PATH}. " + "Run 'python ticktick_auth.py' in the ticktick directory first " + "to complete the OAuth flow and generate .tokens.json." + ) + with open(TOKENS_PATH, "r", encoding="utf-8") as fh: + return json.load(fh) + + +def _save_tokens(tokens: dict[str, str]) -> None: + """Persist tokens to disk.""" + with open(TOKENS_PATH, "w", encoding="utf-8") as fh: + json.dump(tokens, fh, indent=2) + + +async def _refresh_access_token(refresh_token: str) -> dict[str, str]: + """Exchange a refresh token for a new access token.""" + client_id = _vault_get_field("credentials.client_id") + client_secret = _vault_get_field("credentials.client_secret") + + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post( + TICKTICK_OAUTH_TOKEN_URL, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data={ + "client_id": client_id, + "client_secret": client_secret, + "refresh_token": refresh_token, + "grant_type": "refresh_token", + }, + ) + if resp.status_code != 200: + raise RuntimeError( + f"[ERROR] Token refresh failed ({resp.status_code}): {resp.text}" + ) + new_data = resp.json() + + tokens = { + "access_token": new_data["access_token"], + "refresh_token": new_data.get("refresh_token", refresh_token), + "token_type": new_data.get("token_type", "bearer"), + "obtained_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + } + _save_tokens(tokens) + return tokens + + +# --------------------------------------------------------------------------- +# HTTP helper with automatic 401 retry +# --------------------------------------------------------------------------- + + +async def _ticktick_request( + method: str, + path: str, + *, + json_body: Optional[dict] = None, +) -> httpx.Response: + """ + Make an authenticated request to the TickTick API. + + On a 401 response, automatically refreshes the access token and retries + the request exactly once. + """ + tokens = _load_tokens() + + async with httpx.AsyncClient(timeout=30.0) as client: + url = f"{TICKTICK_API_BASE}{path}" + headers = {"Authorization": f"Bearer {tokens['access_token']}"} + kwargs: dict[str, Any] = {"headers": headers} + if json_body is not None: + kwargs["json"] = json_body + + resp = await client.request(method, url, **kwargs) + + if resp.status_code == 401: + # Attempt token refresh and retry once + tokens = await _refresh_access_token(tokens["refresh_token"]) + headers = {"Authorization": f"Bearer {tokens['access_token']}"} + kwargs["headers"] = headers + resp = await client.request(method, url, **kwargs) + + return resp + + +def _format_response(data: Any) -> str: + """Serialize a response payload to pretty JSON text.""" + if isinstance(data, (dict, list)): + return json.dumps(data, indent=2, ensure_ascii=False) + return str(data) + + +def _error_text(msg: str) -> list[TextContent]: + return [TextContent(type="text", text=msg)] + + +# --------------------------------------------------------------------------- +# MCP Server +# --------------------------------------------------------------------------- + +app = Server("ticktick") + + +@app.list_tools() +async def list_tools() -> list[Tool]: + """Enumerate all TickTick tools.""" + return [ + # ----- Projects ----- + Tool( + name="ticktick_list_projects", + description="List all TickTick projects. Returns an array of projects with id, name, color, viewMode, and kind.", + inputSchema={ + "type": "object", + "properties": {}, + }, + ), + Tool( + name="ticktick_get_project", + description="Get a TickTick project and all its tasks by project ID.", + inputSchema={ + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "The project ID to retrieve", + }, + }, + "required": ["project_id"], + }, + ), + Tool( + name="ticktick_create_project", + description="Create a new TickTick project.", + inputSchema={ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Project name (required)", + }, + "color": { + "type": "string", + "description": "Hex color code (e.g. '#ff6347')", + }, + "viewMode": { + "type": "string", + "enum": ["list", "kanban", "timeline"], + "description": "View mode for the project", + }, + "kind": { + "type": "string", + "enum": ["TASK", "NOTE"], + "description": "Project kind (default TASK)", + }, + }, + "required": ["name"], + }, + ), + Tool( + name="ticktick_update_project", + description="Update an existing TickTick project's name, color, or viewMode.", + inputSchema={ + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "The project ID to update", + }, + "name": { + "type": "string", + "description": "New project name", + }, + "color": { + "type": "string", + "description": "New hex color code", + }, + "viewMode": { + "type": "string", + "enum": ["list", "kanban", "timeline"], + "description": "New view mode", + }, + }, + "required": ["project_id"], + }, + ), + Tool( + name="ticktick_delete_project", + description="Delete a TickTick project by ID. This is irreversible.", + inputSchema={ + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "The project ID to delete", + }, + }, + "required": ["project_id"], + }, + ), + # ----- Tasks ----- + Tool( + name="ticktick_create_task", + description="Create a new task in a TickTick project.", + inputSchema={ + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Task title (required)", + }, + "project_id": { + "type": "string", + "description": "Project ID to create the task in (required)", + }, + "content": { + "type": "string", + "description": "Task description / notes", + }, + "priority": { + "type": "integer", + "enum": [0, 1, 3, 5], + "description": "Priority: 0=none, 1=low, 3=medium, 5=high", + }, + "due_date": { + "type": "string", + "description": "Due date in ISO 8601 format (e.g. 2026-04-01T12:00:00+0000)", + }, + "tags": { + "type": "array", + "items": {"type": "string"}, + "description": "List of tag names to attach", + }, + }, + "required": ["title", "project_id"], + }, + ), + Tool( + name="ticktick_update_task", + description="Update an existing task's title, content, priority, due date, or tags.", + inputSchema={ + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "The task ID to update", + }, + "project_id": { + "type": "string", + "description": "The project ID the task belongs to", + }, + "title": { + "type": "string", + "description": "New task title", + }, + "content": { + "type": "string", + "description": "New task description / notes", + }, + "priority": { + "type": "integer", + "enum": [0, 1, 3, 5], + "description": "New priority: 0=none, 1=low, 3=medium, 5=high", + }, + "due_date": { + "type": "string", + "description": "New due date in ISO 8601 format", + }, + "tags": { + "type": "array", + "items": {"type": "string"}, + "description": "Replacement list of tag names", + }, + }, + "required": ["task_id", "project_id"], + }, + ), + Tool( + name="ticktick_complete_task", + description="Mark a TickTick task as completed.", + inputSchema={ + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "The task ID to complete", + }, + "project_id": { + "type": "string", + "description": "The project ID the task belongs to", + }, + }, + "required": ["task_id", "project_id"], + }, + ), + Tool( + name="ticktick_delete_task", + description="Delete a TickTick task. This is irreversible.", + inputSchema={ + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "The task ID to delete", + }, + "project_id": { + "type": "string", + "description": "The project ID the task belongs to", + }, + }, + "required": ["task_id", "project_id"], + }, + ), + ] + + +# --------------------------------------------------------------------------- +# Tool dispatch +# --------------------------------------------------------------------------- + + +@app.call_tool() +async def call_tool(name: str, arguments: Any) -> list[TextContent]: + """Route tool calls to the appropriate handler.""" + try: + if name == "ticktick_list_projects": + return await _handle_list_projects() + elif name == "ticktick_get_project": + return await _handle_get_project(arguments) + elif name == "ticktick_create_project": + return await _handle_create_project(arguments) + elif name == "ticktick_update_project": + return await _handle_update_project(arguments) + elif name == "ticktick_delete_project": + return await _handle_delete_project(arguments) + elif name == "ticktick_create_task": + return await _handle_create_task(arguments) + elif name == "ticktick_update_task": + return await _handle_update_task(arguments) + elif name == "ticktick_complete_task": + return await _handle_complete_task(arguments) + elif name == "ticktick_delete_task": + return await _handle_delete_task(arguments) + else: + return _error_text(f"[ERROR] Unknown tool: {name}") + except FileNotFoundError as exc: + return _error_text(str(exc)) + except RuntimeError as exc: + return _error_text(str(exc)) + except Exception as exc: + return _error_text(f"[ERROR] Unexpected failure in {name}: {exc}") + + +# --------------------------------------------------------------------------- +# Handler implementations +# --------------------------------------------------------------------------- + + +async def _handle_list_projects() -> list[TextContent]: + resp = await _ticktick_request("GET", "/project") + if resp.status_code != 200: + return _error_text( + f"[ERROR] Failed to list projects ({resp.status_code}): {resp.text}" + ) + projects = resp.json() + return [TextContent(type="text", text=f"[OK] {len(projects)} projects found\n\n{_format_response(projects)}")] + + +async def _handle_get_project(args: dict) -> list[TextContent]: + project_id = args["project_id"] + resp = await _ticktick_request("GET", f"/project/{project_id}/data") + if resp.status_code != 200: + return _error_text( + f"[ERROR] Failed to get project {project_id} ({resp.status_code}): {resp.text}" + ) + data = resp.json() + task_count = len(data.get("tasks", [])) + return [TextContent(type="text", text=f"[OK] Project retrieved ({task_count} tasks)\n\n{_format_response(data)}")] + + +async def _handle_create_project(args: dict) -> list[TextContent]: + body: dict[str, Any] = {"name": args["name"]} + for key in ("color", "viewMode", "kind"): + if key in args: + body[key] = args[key] + + resp = await _ticktick_request("POST", "/project", json_body=body) + if resp.status_code not in (200, 201): + return _error_text( + f"[ERROR] Failed to create project ({resp.status_code}): {resp.text}" + ) + project = resp.json() + return [TextContent(type="text", text=f"[OK] Project created\n\n{_format_response(project)}")] + + +async def _handle_update_project(args: dict) -> list[TextContent]: + project_id = args["project_id"] + body: dict[str, Any] = {} + for key in ("name", "color", "viewMode"): + if key in args: + body[key] = args[key] + + if not body: + return _error_text("[WARNING] No update fields provided. Supply at least one of: name, color, viewMode.") + + # TickTick uses POST for project updates in some API versions; fall back to PUT. + resp = await _ticktick_request("POST", f"/project/{project_id}", json_body=body) + if resp.status_code in (404, 405): + resp = await _ticktick_request("PUT", f"/project/{project_id}", json_body=body) + + if resp.status_code not in (200, 201): + return _error_text( + f"[ERROR] Failed to update project {project_id} ({resp.status_code}): {resp.text}" + ) + project = resp.json() + return [TextContent(type="text", text=f"[OK] Project updated\n\n{_format_response(project)}")] + + +async def _handle_delete_project(args: dict) -> list[TextContent]: + project_id = args["project_id"] + resp = await _ticktick_request("DELETE", f"/project/{project_id}") + if resp.status_code not in (200, 204): + return _error_text( + f"[ERROR] Failed to delete project {project_id} ({resp.status_code}): {resp.text}" + ) + return [TextContent(type="text", text=f"[OK] Project {project_id} deleted successfully.")] + + +async def _handle_create_task(args: dict) -> list[TextContent]: + body: dict[str, Any] = { + "title": args["title"], + "projectId": args["project_id"], + } + if "content" in args: + body["content"] = args["content"] + if "priority" in args: + body["priority"] = args["priority"] + if "due_date" in args: + body["dueDate"] = args["due_date"] + if "tags" in args: + body["tags"] = args["tags"] + + resp = await _ticktick_request("POST", "/task", json_body=body) + if resp.status_code not in (200, 201): + return _error_text( + f"[ERROR] Failed to create task ({resp.status_code}): {resp.text}" + ) + task = resp.json() + return [TextContent(type="text", text=f"[OK] Task created\n\n{_format_response(task)}")] + + +async def _handle_update_task(args: dict) -> list[TextContent]: + task_id = args["task_id"] + project_id = args["project_id"] + + body: dict[str, Any] = { + "taskId": task_id, + "projectId": project_id, + } + if "title" in args: + body["title"] = args["title"] + if "content" in args: + body["content"] = args["content"] + if "priority" in args: + body["priority"] = args["priority"] + if "due_date" in args: + body["dueDate"] = args["due_date"] + if "tags" in args: + body["tags"] = args["tags"] + + resp = await _ticktick_request("POST", f"/task/{task_id}", json_body=body) + if resp.status_code not in (200, 201): + return _error_text( + f"[ERROR] Failed to update task {task_id} ({resp.status_code}): {resp.text}" + ) + task = resp.json() + return [TextContent(type="text", text=f"[OK] Task updated\n\n{_format_response(task)}")] + + +async def _handle_complete_task(args: dict) -> list[TextContent]: + task_id = args["task_id"] + project_id = args["project_id"] + resp = await _ticktick_request( + "POST", f"/project/{project_id}/task/{task_id}/complete" + ) + if resp.status_code not in (200, 204): + return _error_text( + f"[ERROR] Failed to complete task {task_id} ({resp.status_code}): {resp.text}" + ) + return [TextContent(type="text", text=f"[OK] Task {task_id} marked as completed.")] + + +async def _handle_delete_task(args: dict) -> list[TextContent]: + task_id = args["task_id"] + project_id = args["project_id"] + resp = await _ticktick_request( + "DELETE", f"/project/{project_id}/task/{task_id}" + ) + if resp.status_code not in (200, 204): + return _error_text( + f"[ERROR] Failed to delete task {task_id} ({resp.status_code}): {resp.text}" + ) + return [TextContent(type="text", text=f"[OK] Task {task_id} deleted successfully.")] + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +async def main() -> None: + """Run the TickTick MCP server over stdio transport.""" + try: + from mcp.server.stdio import stdio_server + + async with stdio_server() as (read_stream, write_stream): + await app.run( + read_stream, + write_stream, + app.create_initialization_options(), + ) + except Exception as exc: + print(f"[ERROR] MCP server failed: {exc}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/migrations/add_dev_projects_table.sql b/migrations/add_dev_projects_table.sql new file mode 100644 index 0000000..831351a --- /dev/null +++ b/migrations/add_dev_projects_table.sql @@ -0,0 +1,22 @@ +-- Migration: Add dev_projects table for tracking development projects +-- Syncs with TickTick "Dev Projects" list (id: 69cbd7138f0826bd72746074) +-- Date: 2026-03-31 + +CREATE TABLE IF NOT EXISTS dev_projects ( + id CHAR(36) PRIMARY KEY DEFAULT (UUID()), + name VARCHAR(200) NOT NULL, + description TEXT, + status ENUM('planning', 'active', 'paused', 'completed', 'archived') NOT NULL DEFAULT 'planning', + ticktick_task_id VARCHAR(100) DEFAULT NULL, + ticktick_project_id VARCHAR(100) DEFAULT '69cbd7138f0826bd72746074', + started_at DATETIME DEFAULT NULL, + completed_at DATETIME DEFAULT NULL, + last_worked_on DATETIME DEFAULT NULL, + total_sessions INT DEFAULT 0, + tags JSON DEFAULT NULL, + notes TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +CREATE INDEX idx_dev_projects_status ON dev_projects(status); diff --git a/session-logs/2026-03-31-session.md b/session-logs/2026-03-31-session.md new file mode 100644 index 0000000..e82c4a9 --- /dev/null +++ b/session-logs/2026-03-31-session.md @@ -0,0 +1,131 @@ +# Session Log: 2026-03-31 - TickTick Integration & Dev Project Tracking + +## Session Summary + +Built a complete TickTick integration for ClaudeTools, including OAuth authentication, MCP server with 9 tools, FastAPI service+router, and a dev project tracking system that syncs between the ClaudeTools database and TickTick. + +### Key Decisions +- **Hybrid approach (Option 3):** TickTick for mobile/cross-device visibility of active dev projects, ClaudeTools DB for granular tracking (sessions, notes, timestamps) +- **MCP server + API service:** Both access paths -- MCP tools for Claude Code direct use, REST API for external access +- **SOPS vault for credentials:** Consistent with project standards, no env vars +- **JWT auth on all router endpoints:** Matches existing security pattern + +### Problems Encountered & Resolutions +1. **"Guru" not appearing in API results:** It's a TickTick folder, not a list. The API only returns lists. "Tasks" and "Call Back List" are the actual lists inside the Guru folder. +2. **Bash not found from PowerShell:** The auth script uses `subprocess.run(["bash", ...])` for vault access. Must run from bash/Claude Code terminal, not PowerShell directly. +3. **DB server unreachable:** 172.16.3.30 not reachable from ACG-5070 without Tailscale. Installed Tailscale via winget, connected, then ran migration. +4. **mcp package not installed:** Installed `mcp` and `httpx` via pip for Python 3.14. +5. **Code review found 4 issues:** All fixed before proceeding -- gitignore, token permissions, JWT auth, SOPS vault credentials. + +--- + +## Credentials + +### TickTick API (OAuth 2.0) +- **Developer Portal:** https://developer.ticktick.com/ +- **App Name:** ClaudeTools +- **Client ID:** 1J86gMsTJ0JH63gtf0 +- **Client Secret:** pI4U78vtLQrZwcW5MmdNFdxA0eeoy7GJ +- **OAuth Redirect URL:** http://localhost:9876/callback +- **Scopes:** tasks:read tasks:write +- **SOPS Vault:** `services/ticktick.sops.yaml` (client_id, client_secret, oauth_redirect_url) +- **Token File:** `mcp-servers/ticktick/.tokens.json` (gitignored, auto-refreshes) + +### TickTick API Endpoints +- **Base URL:** https://api.ticktick.com/open/v1 +- **Auth URL:** https://ticktick.com/oauth/authorize +- **Token URL:** https://ticktick.com/oauth/token +- **Token endpoint requires:** Content-Type: application/x-www-form-urlencoded (NOT JSON) + +### Database +- **Host:** 172.16.3.30:3306 +- **DB:** claudetools +- **User:** claudetools +- **Password:** CT_e8fcd5a3952030a79ed6debae6c954ed + +--- + +## Infrastructure & Servers + +### Tailscale +- Installed on ACG-5070 via `winget install Tailscale.Tailscale` (v1.96.3) +- Required to reach 172.16.3.30 from home network +- Tailscale must be connected before DB/API access works + +### TickTick IDs +- **Dev Projects list ID:** `69cbd7138f0826bd72746074` +- **TickTick Integration task ID:** `69cbe8ca8f0898cc050064e5` +- **DB dev_projects row UUID:** `65783890-2d12-11f1-ae01-52540020ee14` + +### User's TickTick Projects (16 total) +- Call Back List, COSTCO, Private, Capacitance, Website Department, Household Tasks & Projects, PacketDial, Tasks, Grocery, Kitchen Decon, Camper Packing, MOVE 2024, Photography Challenge, Business Planning, Libations shopping, Da Move +- "Guru" is a folder containing "Tasks" (21 items) and "Call Back List" +- "HomeStuff" is another folder (15 items) + +--- + +## Files Created + +### MCP Server +- `mcp-servers/ticktick/ticktick_auth.py` - One-time OAuth browser auth flow (localhost:9876 callback, CSRF protection, vault credential retrieval) +- `mcp-servers/ticktick/ticktick_mcp.py` - MCP server with 9 tools: ticktick_list_projects, ticktick_get_project, ticktick_create_project, ticktick_update_project, ticktick_delete_project, ticktick_create_task, ticktick_update_task, ticktick_complete_task, ticktick_delete_task + +### API Integration +- `api/services/ticktick_service.py` - Async service class with SOPS vault credentials, auto token refresh on 401, httpx client +- `api/routers/ticktick.py` - REST endpoints at `/api/ticktick/`, JWT-protected, 9 endpoints matching MCP tools + +### Database +- `migrations/add_dev_projects_table.sql` - Migration SQL for dev_projects table (14 columns, status index) + +### Configuration +- `.mcp.json` - MCP server registration (ticktick server using python) +- `vault/services/ticktick.sops.yaml` - SOPS-encrypted TickTick credentials + +## Files Modified + +- `api/main.py` - Added ticktick router import and registration at `/api/ticktick/` +- `.gitignore` - Added `**/.tokens.json` to prevent token leakage +- `.claude/memory/MEMORY.md` - Added TickTick integration reference +- `.claude/memory/reference_ticktick_integration.md` - New memory file with full integration details + +## Database Changes + +- **New table:** `dev_projects` (14 columns) with index on status +- **First row inserted:** "TickTick Integration" project, status=active, linked to TickTick task + +## Packages Installed + +- `mcp` (v1.26.0) - MCP protocol library for Python +- `httpx` (v0.28.1) - Async HTTP client +- `pydantic` (v2.12.5) - Data validation (mcp dependency) +- `Tailscale` (v1.96.3) - VPN/mesh networking via winget +- Plus ~25 transitive dependencies + +--- + +## Pending/Incomplete Tasks + +1. **Dev projects API service + router** - Need `api/services/dev_project_service.py` and `api/routers/dev_projects.py` for CRUD on dev_projects table +2. **Bidirectional sync logic** - Auto-update TickTick when DB status changes and vice versa +3. **MCP server testing** - Need to restart Claude Code session to load the TickTick MCP server and test tools +4. **TickTick folder placement** - API can't place "Dev Projects" list inside the "Guru" folder (no folder API). It appears at top level. +5. **Existing project backfill** - Could add existing dev projects (like the TickTick integration itself) to track history + +--- + +## Reference + +### TickTick API Gotchas +- No webhooks (must poll for changes) +- No search endpoint (filter client-side) +- No folder management API +- Priority values non-sequential: 0=none, 1=low, 3=medium, 5=high +- Task update may need POST or PUT (code tries POST first, falls back to PUT) +- Deletions are permanent via API +- Date format: ISO 8601 with timezone offset + +### Re-authentication +If tokens expire completely: `python mcp-servers/ticktick/ticktick_auth.py` (run from bash, not PowerShell) + +### MCP Tools Available (after session restart) +All prefixed with `ticktick_`: list_projects, get_project, create_project, update_project, delete_project, create_task, update_task, complete_task, delete_task