Add TickTick integration, MCP server, and dev project tracking

New integration with TickTick API for project/task management:
- OAuth 2.0 auth flow (mcp-servers/ticktick/ticktick_auth.py)
- MCP server with 9 tools for Claude Code (ticktick_mcp.py)
- FastAPI service with SOPS vault credentials (api/services/ticktick_service.py)
- JWT-protected REST router at /api/ticktick/ (api/routers/ticktick.py)
- Credentials stored in SOPS vault (services/ticktick.sops.yaml)

Dev project tracking (hybrid TickTick + DB):
- New dev_projects table migration (14 columns, status index)
- TickTick "Dev Projects" list for mobile visibility
- First project seeded: TickTick Integration (linked both sides)

Security: .tokens.json gitignored, token file permissions restricted,
HTML-escaped OAuth callback, SOPS vault (not env vars) for secrets.

Also: Installed Tailscale on ACG-5070 for office network access.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 10:08:53 -07:00
parent e34f51fe5d
commit b26e185a80
10 changed files with 2030 additions and 0 deletions

View File

@@ -9,10 +9,12 @@
- [ACG-5070 Workstation](reference_workstation_setup.md) - Windows 11, replaced CachyOS. SOPS vault, Ollama, all dev tools. - [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 - [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 - [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 ## Feedback
- [D2TESTNAS SSH Access](feedback_d2testnas_ssh.md) - Use root@192.168.0.9 with Paper123!@#, not sysadmin - [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 - [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 ## Machine
- [ACG-5070 Workstation Setup](reference_workstation_setup.md) - Windows 11 Pro clean install 2026-03-30, replaced CachyOS. All tools installed. - [ACG-5070 Workstation Setup](reference_workstation_setup.md) - Windows 11 Pro clean install 2026-03-30, replaced CachyOS. All tools installed.

View File

@@ -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).

1
.gitignore vendored
View File

@@ -53,6 +53,7 @@ build/
*.sqlite *.sqlite
logs/ logs/
.claude/tokens.json .claude/tokens.json
**/.tokens.json
.claude/context-recall-config.env .claude/context-recall-config.env
.claude/context-recall-config.env.backup .claude/context-recall-config.env.backup
.claude/context-cache/ .claude/context-cache/

View File

@@ -35,6 +35,7 @@ from api.routers import (
version, version,
quotes, quotes,
admin_quotes, admin_quotes,
ticktick,
) )
# Import middleware # 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(quotes.router, prefix="/api/quotes", tags=["Quotes"])
app.include_router(admin_quotes.router, prefix="/api/admin/quotes", tags=["Admin 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__": if __name__ == "__main__":
import uvicorn import uvicorn

333
api/routers/ticktick.py Normal file
View File

@@ -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)

View File

@@ -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

View File

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

View File

@@ -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())

View File

@@ -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);

View File

@@ -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