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>
596 lines
21 KiB
Python
596 lines
21 KiB
Python
#!/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())
|