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