@boundless-oss/atlas
Version:
Atlas - MCP Server for comprehensive startup project management
851 lines (771 loc) • 26.7 kB
text/typescript
import { JSONSchema7 } from 'json-schema';
import { randomUUID } from 'crypto';
import { createTool, createSuccessResult, createErrorResult } from '../../core/tool-framework.js';
import { ToolRegistration, RequestContext } from '../../core/types.js';
/**
* Kanban Board Tools - 12-Factor MCP Implementation
*
* Implements Factor 2: Deterministic Execution with structured outputs
* Implements Factor 3: Stateless Processes with RequestContext
* Implements Factor 4: Structured Outputs for LLM consumption
*/
// Input type interfaces
interface CreateBoardInput {
name: string;
description?: string;
columns?: string[];
workflowType?: 'simple' | 'development' | 'support' | 'custom';
}
interface CreateTaskInput {
boardId: string;
title: string;
description?: string;
assignedTo?: string;
priority?: 'low' | 'medium' | 'high' | 'critical';
tags?: string[];
dueDate?: string;
storyPoints?: number;
}
interface MoveTaskInput {
taskId: string;
toColumn: string;
position?: number;
}
interface UpdateTaskInput {
taskId: string;
title?: string;
description?: string;
assignedTo?: string;
priority?: 'low' | 'medium' | 'high' | 'critical';
tags?: string[];
dueDate?: string;
storyPoints?: number;
}
interface GetBoardInput {
boardId: string;
includeArchived?: boolean;
}
interface ListTasksInput {
boardId: string;
column?: string;
assignedTo?: string;
priority?: 'low' | 'medium' | 'high' | 'critical';
status?: 'active' | 'archived';
limit?: number;
offset?: number;
}
interface GetBoardAnalyticsInput {
boardId: string;
timeframe?: '24h' | '7d' | '30d' | '90d';
}
// Predefined workflow templates
const WORKFLOW_TEMPLATES = {
simple: ['To Do', 'Doing', 'Done'],
development: ['Backlog', 'To Do', 'In Progress', 'Code Review', 'Testing', 'Done'],
support: ['New', 'Triaged', 'In Progress', 'Waiting for Customer', 'Resolved', 'Closed'],
custom: []
};
/**
* Create a new kanban board
*/
const createBoardTool = createTool<CreateBoardInput, any>({
name: 'create_kanban_board',
description: 'Create a new kanban board with customizable columns and workflow',
category: 'kanban',
inputSchema: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Board name',
minLength: 1,
maxLength: 200
},
description: {
type: 'string',
description: 'Board description',
maxLength: 1000
},
columns: {
type: 'array',
items: { type: 'string', minLength: 1, maxLength: 50 },
description: 'Custom column names (overrides workflowType)',
maxItems: 10
},
workflowType: {
type: 'string',
enum: ['simple', 'development', 'support', 'custom'],
default: 'simple',
description: 'Predefined workflow template'
}
},
required: ['name'],
additionalProperties: false
} as JSONSchema7,
async execute(input: CreateBoardInput, context: RequestContext) {
try {
const boardId = randomUUID();
const now = Date.now();
// Check for duplicate board names
const existingBoardCheck = await context.db.get(
'SELECT id FROM kanban_boards WHERE name = ? AND project_id = ?',
[input.name, context.projectId || 'default']
);
if (existingBoardCheck.success && existingBoardCheck.data) {
return createErrorResult({
code: 'DUPLICATE_RESOURCE',
message: 'A board with this name already exists',
category: 'validation'
});
}
// Determine columns to use
const columns = input.columns && input.columns.length > 0
? input.columns
: WORKFLOW_TEMPLATES[input.workflowType || 'simple'] || WORKFLOW_TEMPLATES.simple;
if (columns.length === 0) {
return createErrorResult({
code: 'INVALID_INPUT',
message: 'At least one column is required',
category: 'validation'
});
}
// Insert board into database
const result = await context.db.run(
`INSERT INTO kanban_boards
(id, name, description, columns, workflow_type, project_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
boardId,
input.name,
input.description || '',
JSON.stringify(columns),
input.workflowType || 'simple',
context.projectId || 'default',
now,
now
]
);
if (!result.success) {
return createErrorResult({
code: 'DATABASE_ERROR',
message: 'Failed to create board',
details: { error: result.error },
category: 'system'
});
}
return createSuccessResult({
board: {
id: boardId,
name: input.name,
description: input.description || '',
columns,
workflowType: input.workflowType || 'simple',
taskCount: 0,
createdAt: new Date(now).toISOString()
},
message: `Kanban board "${input.name}" created successfully with ${columns.length} columns`,
nextSteps: [
'Add tasks to your board',
'Customize column names if needed',
'Set up team member assignments'
]
});
} catch (error) {
return createErrorResult({
code: 'EXECUTION_ERROR',
message: `Failed to create board: ${error instanceof Error ? error.message : 'Unknown error'}`,
category: 'execution'
});
}
}
});
/**
* Create a new task on a kanban board
*/
const createTaskTool = createTool<CreateTaskInput, any>({
name: 'create_kanban_task',
description: 'Create a new task on a kanban board',
category: 'kanban',
inputSchema: {
type: 'object',
properties: {
boardId: {
type: 'string',
description: 'Board ID to create task on',
pattern: '^[a-zA-Z0-9-]+$'
},
title: {
type: 'string',
description: 'Task title',
minLength: 1,
maxLength: 200
},
description: {
type: 'string',
description: 'Task description',
maxLength: 2000
},
assignedTo: {
type: 'string',
description: 'Team member assigned to task',
maxLength: 100
},
priority: {
type: 'string',
enum: ['low', 'medium', 'high', 'critical'],
default: 'medium',
description: 'Task priority level'
},
tags: {
type: 'array',
items: { type: 'string', maxLength: 50 },
description: 'Task tags for categorization',
maxItems: 10
},
dueDate: {
type: 'string',
format: 'date',
description: 'Task due date (YYYY-MM-DD format)'
},
storyPoints: {
type: 'integer',
description: 'Story points for estimation',
minimum: 1,
maximum: 100
}
},
required: ['boardId', 'title'],
additionalProperties: false
} as JSONSchema7,
async execute(input: CreateTaskInput, context: RequestContext) {
try {
// Verify board exists and get first column
const boardResult = await context.db.get(
'SELECT id, name, columns FROM kanban_boards WHERE id = ? AND project_id = ?',
[input.boardId, context.projectId || 'default']
);
if (!boardResult.success || !boardResult.data) {
return createErrorResult({
code: 'RESOURCE_NOT_FOUND',
message: 'Board not found',
details: { boardId: input.boardId },
category: 'validation'
});
}
const board = boardResult.data;
const columns = JSON.parse(board.columns);
const firstColumn = columns[0];
if (!firstColumn) {
return createErrorResult({
code: 'INVALID_STATE',
message: 'Board has no columns',
details: { boardId: input.boardId },
category: 'validation'
});
}
const taskId = randomUUID();
const now = Date.now();
// Get next position in first column
const positionResult = await context.db.get(
'SELECT MAX(position) as maxPosition FROM kanban_tasks WHERE board_id = ? AND column_name = ?',
[input.boardId, firstColumn]
);
const nextPosition = (positionResult.data?.maxPosition || 0) + 1;
// Validate due date if provided
let dueDateTimestamp = null;
if (input.dueDate) {
const dueDate = new Date(input.dueDate);
if (isNaN(dueDate.getTime())) {
return createErrorResult({
code: 'INVALID_INPUT',
message: 'Invalid due date format',
category: 'validation'
});
}
dueDateTimestamp = dueDate.getTime();
}
// Insert task into database
const result = await context.db.run(
`INSERT INTO kanban_tasks
(id, board_id, title, description, assigned_to, priority, tags,
due_date, story_points, column_name, position, status,
project_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
taskId,
input.boardId,
input.title,
input.description || '',
input.assignedTo || null,
input.priority || 'medium',
JSON.stringify(input.tags || []),
dueDateTimestamp,
input.storyPoints || null,
firstColumn,
nextPosition,
'active',
context.projectId || 'default',
now,
now
]
);
if (!result.success) {
return createErrorResult({
code: 'DATABASE_ERROR',
message: 'Failed to create task',
details: { error: result.error },
category: 'system'
});
}
return createSuccessResult({
task: {
id: taskId,
boardId: input.boardId,
title: input.title,
description: input.description || '',
assignedTo: input.assignedTo || null,
priority: input.priority || 'medium',
tags: input.tags || [],
dueDate: input.dueDate || null,
storyPoints: input.storyPoints || null,
column: firstColumn,
position: nextPosition,
status: 'active',
createdAt: new Date(now).toISOString()
},
message: `Task "${input.title}" created on board "${board.name}" in column "${firstColumn}"`,
boardName: board.name,
column: firstColumn
});
} catch (error) {
return createErrorResult({
code: 'EXECUTION_ERROR',
message: `Failed to create task: ${error instanceof Error ? error.message : 'Unknown error'}`,
category: 'execution'
});
}
}
});
/**
* Move a task between columns
*/
const moveTaskTool = createTool<MoveTaskInput, any>({
name: 'move_kanban_task',
description: 'Move a task to a different column on the kanban board',
category: 'kanban',
inputSchema: {
type: 'object',
properties: {
taskId: {
type: 'string',
description: 'Task ID to move',
pattern: '^[a-zA-Z0-9-]+$'
},
toColumn: {
type: 'string',
description: 'Target column name',
minLength: 1,
maxLength: 50
},
position: {
type: 'integer',
description: 'Position in target column (0 = top)',
minimum: 0
}
},
required: ['taskId', 'toColumn'],
additionalProperties: false
} as JSONSchema7,
async execute(input: MoveTaskInput, context: RequestContext) {
try {
// Get task and verify it exists
const taskResult = await context.db.get(
`SELECT t.*, b.name as board_name, b.columns
FROM kanban_tasks t
JOIN kanban_boards b ON t.board_id = b.id
WHERE t.id = ? AND t.project_id = ?`,
[input.taskId, context.projectId || 'default']
);
if (!taskResult.success || !taskResult.data) {
return createErrorResult({
code: 'RESOURCE_NOT_FOUND',
message: 'Task not found',
details: { taskId: input.taskId },
category: 'validation'
});
}
const task = taskResult.data;
const columns = JSON.parse(task.columns);
// Verify target column exists
if (!columns.includes(input.toColumn)) {
return createErrorResult({
code: 'INVALID_INPUT',
message: `Column "${input.toColumn}" does not exist on this board`,
details: { availableColumns: columns },
category: 'validation'
});
}
const oldColumn = task.column_name;
// If moving to same column, just update position
if (oldColumn === input.toColumn && input.position !== undefined) {
// Update positions
await context.db.transaction(async (tx) => {
// Get current max position in target column
const maxPosResult = await tx.get(
'SELECT MAX(position) as maxPosition FROM kanban_tasks WHERE board_id = ? AND column_name = ?',
[task.board_id, input.toColumn]
);
const maxPosition = maxPosResult?.maxPosition || 0;
const targetPosition = Math.min(input.position, maxPosition);
// Shift other tasks if needed
if (targetPosition < task.position) {
// Moving up - shift others down
await tx.run(
'UPDATE kanban_tasks SET position = position + 1 WHERE board_id = ? AND column_name = ? AND position >= ? AND position < ?',
[task.board_id, input.toColumn, targetPosition, task.position]
);
} else if (targetPosition > task.position) {
// Moving down - shift others up
await tx.run(
'UPDATE kanban_tasks SET position = position - 1 WHERE board_id = ? AND column_name = ? AND position > ? AND position <= ?',
[task.board_id, input.toColumn, task.position, targetPosition]
);
}
// Update task position
await tx.run(
'UPDATE kanban_tasks SET position = ?, updated_at = ? WHERE id = ?',
[targetPosition, Date.now(), input.taskId]
);
});
} else {
// Moving between columns
await context.db.transaction(async (tx) => {
// Get next position in target column
const maxPosResult = await tx.get(
'SELECT MAX(position) as maxPosition FROM kanban_tasks WHERE board_id = ? AND column_name = ?',
[task.board_id, input.toColumn]
);
const targetPosition = input.position !== undefined
? Math.min(input.position, (maxPosResult?.maxPosition || 0) + 1)
: (maxPosResult?.maxPosition || 0) + 1;
// Shift tasks in target column if needed
if (input.position !== undefined) {
await tx.run(
'UPDATE kanban_tasks SET position = position + 1 WHERE board_id = ? AND column_name = ? AND position >= ?',
[task.board_id, input.toColumn, targetPosition]
);
}
// Shift tasks in old column up
await tx.run(
'UPDATE kanban_tasks SET position = position - 1 WHERE board_id = ? AND column_name = ? AND position > ?',
[task.board_id, oldColumn, task.position]
);
// Update task column and position
await tx.run(
'UPDATE kanban_tasks SET column_name = ?, position = ?, updated_at = ? WHERE id = ?',
[input.toColumn, targetPosition, Date.now(), input.taskId]
);
});
}
return createSuccessResult({
message: `Task "${task.title}" moved from "${oldColumn}" to "${input.toColumn}"`,
taskId: input.taskId,
taskTitle: task.title,
fromColumn: oldColumn,
toColumn: input.toColumn,
boardName: task.board_name,
newPosition: input.position
});
} catch (error) {
return createErrorResult({
code: 'EXECUTION_ERROR',
message: `Failed to move task: ${error instanceof Error ? error.message : 'Unknown error'}`,
category: 'execution'
});
}
}
});
/**
* Get board with all tasks
*/
const getBoardTool = createTool<GetBoardInput, any>({
name: 'get_kanban_board',
description: 'Get a kanban board with all its tasks organized by columns',
category: 'kanban',
readOnly: true,
inputSchema: {
type: 'object',
properties: {
boardId: {
type: 'string',
description: 'Board ID to retrieve',
pattern: '^[a-zA-Z0-9-]+$'
},
includeArchived: {
type: 'boolean',
description: 'Include archived tasks',
default: false
}
},
required: ['boardId'],
additionalProperties: false
} as JSONSchema7,
async execute(input: GetBoardInput, context: RequestContext) {
try {
// Get board info
const boardResult = await context.db.get(
'SELECT * FROM kanban_boards WHERE id = ? AND project_id = ?',
[input.boardId, context.projectId || 'default']
);
if (!boardResult.success || !boardResult.data) {
return createErrorResult({
code: 'RESOURCE_NOT_FOUND',
message: 'Board not found',
details: { boardId: input.boardId },
category: 'validation'
});
}
const board = boardResult.data;
const columns = JSON.parse(board.columns);
// Get all tasks for the board
const tasksQuery = input.includeArchived
? 'SELECT * FROM kanban_tasks WHERE board_id = ? ORDER BY column_name, position'
: 'SELECT * FROM kanban_tasks WHERE board_id = ? AND status = ? ORDER BY column_name, position';
const tasksParams = input.includeArchived
? [input.boardId]
: [input.boardId, 'active'];
const tasksResult = await context.db.query(tasksQuery, tasksParams);
if (!tasksResult.success) {
return createErrorResult({
code: 'DATABASE_ERROR',
message: 'Failed to get board tasks',
details: { error: tasksResult.error },
category: 'system'
});
}
// Organize tasks by column
const tasksByColumn: Record<string, any[]> = {};
columns.forEach((column: string) => {
tasksByColumn[column] = [];
});
(tasksResult.data || []).forEach((task: any) => {
if (tasksByColumn[task.column_name]) {
tasksByColumn[task.column_name].push({
id: task.id,
title: task.title,
description: task.description,
assignedTo: task.assigned_to,
priority: task.priority,
tags: JSON.parse(task.tags || '[]'),
dueDate: task.due_date ? new Date(task.due_date).toISOString() : null,
storyPoints: task.story_points,
position: task.position,
status: task.status,
createdAt: new Date(task.created_at).toISOString(),
updatedAt: new Date(task.updated_at).toISOString()
});
}
});
// Calculate statistics
const totalTasks = (tasksResult.data || []).length;
const tasksByStatus = (tasksResult.data || []).reduce((acc: any, task: any) => {
acc[task.status] = (acc[task.status] || 0) + 1;
return acc;
}, {});
return createSuccessResult({
board: {
id: board.id,
name: board.name,
description: board.description,
columns,
workflowType: board.workflow_type,
createdAt: new Date(board.created_at).toISOString(),
updatedAt: new Date(board.updated_at).toISOString()
},
tasksByColumn,
statistics: {
totalTasks,
tasksByStatus,
tasksByColumn: Object.fromEntries(
columns.map((col: string) => [col, tasksByColumn[col].length])
)
}
});
} catch (error) {
return createErrorResult({
code: 'EXECUTION_ERROR',
message: `Failed to get board: ${error instanceof Error ? error.message : 'Unknown error'}`,
category: 'execution'
});
}
}
});
/**
* Get board analytics and insights
*/
const getBoardAnalyticsTool = createTool<GetBoardAnalyticsInput, any>({
name: 'get_kanban_analytics',
description: 'Get analytics and insights for a kanban board including cycle time and throughput',
category: 'kanban',
readOnly: true,
inputSchema: {
type: 'object',
properties: {
boardId: {
type: 'string',
description: 'Board ID to analyze',
pattern: '^[a-zA-Z0-9-]+$'
},
timeframe: {
type: 'string',
enum: ['24h', '7d', '30d', '90d'],
default: '30d',
description: 'Analysis timeframe'
}
},
required: ['boardId'],
additionalProperties: false
} as JSONSchema7,
async execute(input: GetBoardAnalyticsInput, context: RequestContext) {
try {
// Verify board exists
const boardResult = await context.db.get(
'SELECT name, columns FROM kanban_boards WHERE id = ? AND project_id = ?',
[input.boardId, context.projectId || 'default']
);
if (!boardResult.success || !boardResult.data) {
return createErrorResult({
code: 'RESOURCE_NOT_FOUND',
message: 'Board not found',
details: { boardId: input.boardId },
category: 'validation'
});
}
const board = boardResult.data;
const columns = JSON.parse(board.columns);
// Calculate time threshold
const now = Date.now();
let timeThreshold = now;
switch (input.timeframe) {
case '24h':
timeThreshold = now - (24 * 60 * 60 * 1000);
break;
case '7d':
timeThreshold = now - (7 * 24 * 60 * 60 * 1000);
break;
case '30d':
timeThreshold = now - (30 * 24 * 60 * 60 * 1000);
break;
case '90d':
timeThreshold = now - (90 * 24 * 60 * 60 * 1000);
break;
}
// Get task statistics for the timeframe
const tasksResult = await context.db.query(
`SELECT
COUNT(*) as total_tasks,
COUNT(CASE WHEN status = 'active' THEN 1 END) as active_tasks,
COUNT(CASE WHEN status = 'archived' THEN 1 END) as completed_tasks,
AVG(CASE WHEN status = 'archived' THEN updated_at - created_at END) as avg_cycle_time,
column_name,
priority,
assigned_to
FROM kanban_tasks
WHERE board_id = ? AND created_at > ?
GROUP BY column_name, priority, assigned_to`,
[input.boardId, timeThreshold]
);
// Get task distribution by column
const columnStatsResult = await context.db.query(
'SELECT column_name, COUNT(*) as count FROM kanban_tasks WHERE board_id = ? AND status = ? GROUP BY column_name',
[input.boardId, 'active']
);
const columnStats = (columnStatsResult.data || []).reduce((acc: any, row: any) => {
acc[row.column_name] = row.count;
return acc;
}, {});
// Ensure all columns are represented
columns.forEach((col: string) => {
if (!(col in columnStats)) {
columnStats[col] = 0;
}
});
// Calculate throughput (completed tasks)
const throughputResult = await context.db.get(
'SELECT COUNT(*) as completed FROM kanban_tasks WHERE board_id = ? AND status = ? AND updated_at > ?',
[input.boardId, 'archived', timeThreshold]
);
const throughput = throughputResult.data?.completed || 0;
// Get priority distribution
const priorityResult = await context.db.query(
'SELECT priority, COUNT(*) as count FROM kanban_tasks WHERE board_id = ? AND status = ? GROUP BY priority',
[input.boardId, 'active']
);
const priorityStats = (priorityResult.data || []).reduce((acc: any, row: any) => {
acc[row.priority] = row.count;
return acc;
}, {});
// Get assignee workload
const assigneeResult = await context.db.query(
'SELECT assigned_to, COUNT(*) as count FROM kanban_tasks WHERE board_id = ? AND status = ? AND assigned_to IS NOT NULL GROUP BY assigned_to',
[input.boardId, 'active']
);
const assigneeWorkload = (assigneeResult.data || []).reduce((acc: any, row: any) => {
acc[row.assigned_to] = row.count;
return acc;
}, {});
const taskStats = tasksResult.data?.[0] || {};
const avgCycleTimeMs = taskStats.avg_cycle_time || 0;
const avgCycleTimeDays = avgCycleTimeMs > 0 ? Math.round((avgCycleTimeMs / (1000 * 60 * 60 * 24)) * 100) / 100 : 0;
return createSuccessResult({
board: {
id: input.boardId,
name: board.name
},
timeframe: input.timeframe,
metrics: {
totalTasks: taskStats.total_tasks || 0,
activeTasks: taskStats.active_tasks || 0,
completedTasks: taskStats.completed_tasks || 0,
throughput,
averageCycleTime: {
milliseconds: avgCycleTimeMs,
days: avgCycleTimeDays,
humanReadable: avgCycleTimeDays > 0 ? `${avgCycleTimeDays} days` : 'No data'
}
},
distribution: {
byColumn: columnStats,
byPriority: priorityStats,
byAssignee: assigneeWorkload
},
insights: [
throughput > 0 ? `Completed ${throughput} tasks in the last ${input.timeframe}` : 'No tasks completed in timeframe',
avgCycleTimeDays > 0 ? `Average cycle time: ${avgCycleTimeDays} days` : 'No cycle time data available',
Object.keys(assigneeWorkload).length > 0 ? `${Object.keys(assigneeWorkload).length} team members have active tasks` : 'No assigned tasks'
]
});
} catch (error) {
return createErrorResult({
code: 'EXECUTION_ERROR',
message: `Failed to get board analytics: ${error instanceof Error ? error.message : 'Unknown error'}`,
category: 'execution'
});
}
}
});
/**
* Setup kanban tools
*/
export async function setupKanbanTools(): Promise<ToolRegistration> {
return {
module: 'kanban',
tools: [
createBoardTool,
createTaskTool,
moveTaskTool,
getBoardTool,
getBoardAnalyticsTool
]
};
}