#!/usr/bin/env python3
"""
TickTick MCP Server - Model Context Protocol server for TickTick integration
Exposes TickTick import/export functionality as MCP tools that Claude can use.
"""
import os
import sys
from mcp.server.fastmcp import FastMCP
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
# Note: MCP uses stdio for communication. Do not print to stdout.
# For debugging, use: print(..., file=sys.stderr)
from src.api.ticktick_api import TickTickAPI
from src.exporters.notes_exporter import NotesManager, MarkdownExporter, JSONExporter
from src.importers.task_importer import TaskImporter
# Initialize FastMCP server
mcp = FastMCP("ticktick")
def get_api_client() -> TickTickAPI:
"""Get authenticated TickTick API client"""
token = os.getenv('TICKTICK_ACCESS_TOKEN')
if not token:
raise ValueError(
"TICKTICK_ACCESS_TOKEN not set. "
"Run: python -m src.utils.get_token"
)
return TickTickAPI(token)
@mcp.tool()
async def list_projects() -> str:
"""List all TickTick projects/lists.
Returns:
Formatted list of all TickTick projects with their IDs.
"""
try:
api = get_api_client()
projects = api.get_projects()
if not projects:
return "No projects found in your TickTick account."
result = "TickTick Projects:\n" + "="*50 + "\n"
for project in projects:
result += f"• {project['name']} (ID: {project['id']})\n"
result += "="*50
return result
except Exception as e:
return f"Error listing projects: {str(e)}"
@mcp.tool()
async def list_tags(project_name: str) -> str:
"""List all tags in a specific project.
Args:
project_name: Name of the TickTick project to list tags from
Returns:
List of tags found in the specified project.
"""
try:
manager = NotesManager()
if not manager.authenticate():
return "Authentication failed. Check your access token."
project = manager.find_project(project_name)
if not project:
return f"Project '{project_name}' not found."
tags = manager.list_tags(project_name)
if not tags:
return f"No tags found in project '{project_name}'."
result = f"Tags in '{project_name}':\n" + "="*50 + "\n"
for tag in sorted(tags):
result += f"• {tag}\n"
result += "="*50
return result
except Exception as e:
return f"Error listing tags: {str(e)}"
@mcp.tool()
async def export_by_tag(
tag_name: str,
project_name: str,
format: str = "markdown"
) -> str:
"""Export tasks filtered by a specific tag from a project.
Args:
tag_name: Tag to filter tasks by
project_name: TickTick project name to export from
format: Output format - 'markdown' or 'json' (default: markdown)
Returns:
Exported tasks in the specified format.
"""
try:
manager = NotesManager()
if not manager.authenticate():
return "Authentication failed. Check your access token."
# Use a temporary file for export
import tempfile
suffix = ".md" if format == "markdown" else ".json"
with tempfile.NamedTemporaryFile(mode='w', suffix=suffix, delete=False) as tmp:
output_file = tmp.name
exporter = MarkdownExporter() if format == "markdown" else JSONExporter()
manager.export_by_tag(
tag_name=tag_name,
output_file=output_file,
project_name=project_name,
exporter=exporter
)
# Read and return the exported content
with open(output_file, 'r', encoding='utf-8') as f:
content = f.read()
# Clean up temp file
os.unlink(output_file)
return content
except Exception as e:
return f"Error exporting by tag: {str(e)}"
@mcp.tool()
async def export_project(
project_name: str,
format: str = "markdown"
) -> str:
"""Export all tasks from an entire project.
Args:
project_name: TickTick project name to export
format: Output format - 'markdown' or 'json' (default: markdown)
Returns:
All tasks from the project in the specified format.
"""
try:
manager = NotesManager()
if not manager.authenticate():
return "Authentication failed. Check your access token."
# Use a temporary file for export
import tempfile
suffix = ".md" if format == "markdown" else ".json"
with tempfile.NamedTemporaryFile(mode='w', suffix=suffix, delete=False) as tmp:
output_file = tmp.name
exporter = MarkdownExporter() if format == "markdown" else JSONExporter()
manager.export_project(
project_name=project_name,
output_file=output_file,
exporter=exporter
)
# Read and return the exported content
with open(output_file, 'r', encoding='utf-8') as f:
content = f.read()
# Clean up temp file
os.unlink(output_file)
return content
except Exception as e:
return f"Error exporting project: {str(e)}"
@mcp.tool()
async def import_from_markdown(
markdown_content: str,
project_name: str = None,
dry_run: bool = True
) -> str:
"""Import tasks from Markdown content into TickTick.
Args:
markdown_content: Markdown formatted task list with metadata
project_name: Target TickTick project (defaults to Inbox if not specified)
dry_run: If True, preview without creating tasks. Set to False to actually create tasks.
Returns:
Summary of the import operation showing created/skipped/failed tasks.
"""
try:
importer = TaskImporter()
if not importer.authenticate():
return "Authentication failed. Check your access token."
# Write markdown content to temporary file
import tempfile
with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False, encoding='utf-8') as tmp:
tmp.write(markdown_content)
temp_file = tmp.name
# Import from the temp file
result = importer.import_from_file(
file_path=temp_file,
default_project=project_name,
dry_run=dry_run
)
# Clean up temp file
os.unlink(temp_file)
# Format result summary
if 'error' in result:
return f"Import failed: {result['error']}"
if result.get('dry_run'):
return f"DRY RUN - Preview of {result['tasks']} tasks parsed. Set dry_run=False to actually import."
summary = "Import Summary:\n" + "="*50 + "\n"
summary += f"Total tasks: {result['total']}\n"
summary += f"✓ Created: {result['created']}\n"
summary += f"⊘ Skipped: {result['skipped']}\n"
summary += f"✗ Failed: {result['failed']}\n"
summary += "="*50
if result['errors']:
summary += "\n\nErrors:\n"
for error in result['errors']:
summary += f"• {error['task']}: {error['error']}\n"
return summary
except Exception as e:
return f"Error importing tasks: {str(e)}"
@mcp.tool()
async def create_task(
title: str,
project_name: str = None,
content: str = None,
tags: list[str] = None,
due_date: str = None
) -> str:
"""Create a single task in TickTick.
Args:
title: Task title
project_name: Project to add task to (defaults to Inbox)
content: Optional task description/content
tags: Optional list of tags to add to the task
due_date: Optional due date in format YYYY-MM-DD (e.g., "2026-01-28") or ISO format
Returns:
Confirmation message with created task details.
"""
try:
api = get_api_client()
importer = TaskImporter()
# Find project ID if project name provided
project_id = None
if project_name:
project_id = importer.find_project_id(project_name)
if not project_id:
return f"Error: Project '{project_name}' not found."
# Format due_date to ISO format if provided as simple date
formatted_due_date = None
if due_date:
if 'T' not in due_date:
# Simple date format YYYY-MM-DD, convert to ISO with end of day (Brisbane AEST UTC+10)
formatted_due_date = f"{due_date}T23:59:59.000+1000"
else:
formatted_due_date = due_date
# Create the task
result = api.create_task(
title=title,
content=content,
project_id=project_id,
tags=tags,
due_date=formatted_due_date
)
if not result:
return "Failed to create task. Check your credentials."
task_info = f"✓ Task created successfully!\n"
task_info += f" Title: {title}\n"
if project_name:
task_info += f" Project: {project_name}\n"
if due_date:
task_info += f" Due: {due_date}\n"
if tags:
task_info += f" Tags: {', '.join(tags)}\n"
if content:
task_info += f" Description: {content[:100]}...\n"
return task_info
except Exception as e:
return f"Error creating task: {str(e)}"
@mcp.tool()
async def list_tasks(project_name: str) -> str:
"""List all tasks in a specific project.
Args:
project_name: Name of the TickTick project to list tasks from
Returns:
Formatted list of tasks with their IDs, titles, and due dates.
"""
try:
api = get_api_client()
projects = api.get_projects()
# Find the project by name
project_id = None
for project in projects:
if project['name'].lower() == project_name.lower():
project_id = project['id']
break
if not project_id:
return f"Project '{project_name}' not found."
# Get project data which includes tasks
project_data = api.get_projects_data(project_id)
if not project_data:
return f"Could not retrieve tasks for project '{project_name}'."
tasks = project_data.get('tasks', [])
if not tasks:
return f"No tasks found in project '{project_name}'."
result = f"Tasks in '{project_name}':\n" + "="*60 + "\n"
for task in tasks:
due = task.get('dueDate', 'No due date')
if due and due != 'No due date':
due = due[:10] # Just show YYYY-MM-DD
result += f"• {task['title']}\n"
result += f" ID: {task['id']}\n"
result += f" Due: {due}\n"
result += f" Project ID: {task.get('projectId', 'N/A')}\n\n"
result += "="*60
return result
except Exception as e:
return f"Error listing tasks: {str(e)}"
@mcp.tool()
async def update_task(
task_id: str,
project_id: str,
title: str = None,
due_date: str = None,
content: str = None
) -> str:
"""Update an existing task in TickTick.
Args:
task_id: ID of the task to update
project_id: ID of the project the task belongs to
title: Optional new title for the task
due_date: Optional due date in format YYYY-MM-DD (e.g., "2026-01-28") or ISO format
content: Optional new description/content
Returns:
Confirmation message with updated task details.
"""
try:
api = get_api_client()
# Format due_date to ISO format if provided as simple date
formatted_due_date = None
if due_date:
if 'T' not in due_date:
# Brisbane AEST UTC+10
formatted_due_date = f"{due_date}T23:59:59.000+1000"
else:
formatted_due_date = due_date
result = api.update_task(
task_id=task_id,
project_id=project_id,
title=title,
content=content,
due_date=formatted_due_date
)
if not result:
return "Failed to update task. Check your credentials and task/project IDs."
task_info = f"✓ Task updated successfully!\n"
task_info += f" Task ID: {task_id}\n"
if title:
task_info += f" New Title: {title}\n"
if due_date:
task_info += f" Due: {due_date}\n"
return task_info
except Exception as e:
return f"Error updating task: {str(e)}"
def main():
"""Run the MCP server"""
# The server runs on stdio transport (standard for MCP)
mcp.run(transport="stdio")
if __name__ == "__main__":
main()