"""Project tools."""
from typing import Optional
from mcp.server.fastmcp import FastMCP
from .. import client
from ..types import ClinkApiError, Project
def _format_project(p: Project) -> str:
"""Format a project for display."""
output = f"Title: {p.title}\n"
output += f"ID: {p.project_id}\n"
output += f"Slug: {p.slug}\n"
output += f"Status: {p.status}\n"
if p.description:
output += f"Description: {p.description}\n"
if p.color:
output += f"Color: {p.color}\n"
if p.is_default:
output += "Default: Yes\n"
return output
def register(mcp: FastMCP) -> None:
"""Register project tools with the MCP server."""
@mcp.tool()
async def create_project(
group: str,
title: str,
description: Optional[str] = None,
slug: Optional[str] = None,
color: Optional[str] = None,
) -> str:
"""Create a new project in a Clink group. Projects organize milestones and help track related work. Each project has a unique slug within the group.
Args:
group: The group slug (e.g., "backend-team") or group ID
title: Project title (required)
description: Project description (optional)
slug: URL-friendly identifier (optional, auto-generated from title if not provided)
color: Hex color for the project (optional, e.g., "#3B82F6")
"""
try:
if not group:
groups = await client.list_groups()
if groups:
group_list = "\n".join(f'* {g.slug} - "{g.name}"' for g in groups)
return f"Please provide a group slug. Your groups:\n\n{group_list}"
return "Please provide a group slug."
if not title:
return "Please provide a project title."
group_id = await client.resolve_group(group)
project = await client.create_project(
group_id,
title=title,
description=description,
slug=slug,
color=color,
)
output = "Project created successfully!\n\n"
output += _format_project(project)
return output
except ClinkApiError as e:
if e.status_code == 409:
return f"A project with slug '{slug}' already exists in this group."
return f"Error: {e}"
@mcp.tool()
async def list_projects(
group: str,
status: Optional[str] = None,
limit: Optional[int] = None,
) -> str:
"""List projects for a Clink group. Shows status and milestone organization.
Args:
group: The group slug (e.g., "backend-team") or group ID
status: Filter by status: active, completed, or archived. Default: all.
limit: Maximum projects to return (default: 50)
"""
try:
if not group:
groups = await client.list_groups()
if groups:
group_list = "\n".join(f'* {g.slug} - "{g.name}"' for g in groups)
return f"Please provide a group slug. Your groups:\n\n{group_list}"
return "Please provide a group slug."
group_id = await client.resolve_group(group)
projects = await client.list_projects(
group_id,
status=status, # type: ignore
limit=limit,
)
if not projects:
return f"No projects found in group '{group}'."
output = f"Projects in {group} ({len(projects)}):\n\n"
for p in projects:
default_marker = " [default]" if p.is_default else ""
output += f"* {p.title}{default_marker}\n"
output += f" ID: {p.project_id}\n"
output += f" Slug: {p.slug} | Status: {p.status}\n\n"
return output.strip()
except ClinkApiError as e:
return f"Error: {e}"
@mcp.tool()
async def get_project(project_id: str) -> str:
"""Get detailed information about a project including its milestones.
Args:
project_id: The project ID
"""
try:
if not project_id:
return "Please provide a project_id."
project = await client.get_project(project_id)
return _format_project(project)
except ClinkApiError as e:
if e.status_code == 404:
return f"Project not found: {project_id}"
return f"Error: {e}"
@mcp.tool()
async def update_project(
project_id: str,
title: Optional[str] = None,
description: Optional[str] = None,
slug: Optional[str] = None,
color: Optional[str] = None,
) -> str:
"""Update a project's title, description, slug, or color.
Args:
project_id: The project ID
title: New title (optional)
description: New description (optional)
slug: New slug (optional)
color: New hex color (optional, e.g., "#3B82F6")
"""
try:
if not project_id:
return "Please provide a project_id."
if not any([title, description, slug, color]):
return "Please provide at least one field to update."
project = await client.update_project(
project_id,
title=title,
description=description,
slug=slug,
color=color,
)
output = "Project updated!\n\n"
output += _format_project(project)
return output
except ClinkApiError as e:
if e.status_code == 404:
return f"Project not found: {project_id}"
if e.status_code == 409:
return f"A project with slug '{slug}' already exists in this group."
return f"Error: {e}"
@mcp.tool()
async def complete_project(project_id: str) -> str:
"""Mark a project as completed. Completed projects remain visible but indicate all work is done.
Args:
project_id: The project ID
"""
try:
if not project_id:
return "Please provide a project_id."
project = await client.complete_project(project_id)
output = "Project marked as completed!\n\n"
output += _format_project(project)
return output
except ClinkApiError as e:
if e.status_code == 404:
return f"Project not found: {project_id}"
if e.status_code == 409:
return "Project is already completed or cannot be completed."
return f"Error: {e}"
@mcp.tool()
async def archive_project(project_id: str) -> str:
"""Archive a project. Archived projects are hidden from default views but can be reopened later. Cannot archive the default project.
Args:
project_id: The project ID
"""
try:
if not project_id:
return "Please provide a project_id."
project = await client.archive_project(project_id)
output = "Project archived.\n\n"
output += _format_project(project)
return output
except ClinkApiError as e:
if e.status_code == 404:
return f"Project not found: {project_id}"
if e.status_code == 409:
return "Cannot archive this project. It may be the default project."
return f"Error: {e}"
@mcp.tool()
async def reopen_project(project_id: str) -> str:
"""Re-open a completed or archived project. This allows adding new milestones or continuing work.
Args:
project_id: The project ID to reopen
"""
try:
if not project_id:
return "Please provide a project_id."
project = await client.reopen_project(project_id)
output = "Project reopened!\n\n"
output += _format_project(project)
return output
except ClinkApiError as e:
if e.status_code == 404:
return f"Project not found: {project_id}"
if e.status_code == 409:
return "Project is already active."
return f"Error: {e}"