#!/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())