Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions alfy-bot/src/modules/project/project.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { JwtOrApiTokenGuard } from '../auth/guards/jwt-or-api-token.guard';
import { JwtPayload } from '../auth/strategies/jwt.strategy';
import { ProjectService } from './project.service';
import { CreateProjectDto } from './dto/create-project.dto';
Expand All @@ -23,7 +23,7 @@ interface AuthRequest extends Request {

@ApiTags('projects')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@UseGuards(JwtOrApiTokenGuard)
@Controller('projects')
export class ProjectController {
constructor(private readonly projectService: ProjectService) {}
Expand Down
14 changes: 13 additions & 1 deletion alfy-mcp/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# alfy-mcp

MCP-сервер для [Alfy](../README.md) — предоставляет инструменты для управления задачами, целями и привычками через Claude Desktop, Claude Code или любой MCP-клиент.
MCP-сервер для [Alfy](../README.md) — предоставляет инструменты для управления задачами, проектами, целями и привычками через Claude Desktop, Claude Code или любой MCP-клиент.

ESM-пакет, Node 22+. Тонкая обёртка над REST API [`alfy-bot`](../alfy-bot/): один HTTP-вызов на инструмент (кроме `get_progress` — 3 параллельных), в БД напрямую не ходит. SDK — [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol). Транспорты: stdio + Streamable HTTP (endpoint `/mcp`, порт 3003).

Expand Down Expand Up @@ -64,6 +64,18 @@ URL: `https://tracker.rocketup.tech/mcp`
| `complete_task` | Отметить задачу выполненной |
| `delete_task` | Удалить задачу |

Перенос задачи: `update_task` с `projectId` (UUID) или `null` (Inbox).

### Проекты

| Инструмент | Описание |
|---|---|
| `list_projects` | Плоский список: `id, title, parentId, description, viewMode, icon, color, order` |
| `create_project` | Создать проект (`title`, опц. `parentId`, `description`, `viewMode`, `icon`, `color`) |
| `update_project` | Обновить поля; `null` снимает `parentId` / `description` / `icon` / `color` |
| `delete_project` | Удалить. Нужен `confirm: true`. 409, если есть дочерние проекты или задачи |
| `reorder_projects` | Порядок: полный массив `orderedIds` |

### Привычки / Вопросы (Habits)

| Инструмент | Описание |
Expand Down
2 changes: 2 additions & 0 deletions alfy-mcp/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { registerGoalTools } from './tools/goals.js';
import { registerTaskTools } from './tools/tasks.js';
import { registerQuestionTools } from './tools/questions.js';
import { registerProgressTools } from './tools/progress.js';
import { registerProjectTools } from './tools/projects.js';

/**
* Factory: creates and returns an McpServer instance with all tools registered.
Expand All @@ -19,6 +20,7 @@ export function createServer(client: AlfyRestClient): McpServer {
registerTaskTools(server, client);
registerQuestionTools(server, client);
registerProgressTools(server, client);
registerProjectTools(server, client);

return server;
}
142 changes: 142 additions & 0 deletions alfy-mcp/src/tools/projects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { AlfyRestClient } from '../rest-client.js';
import { RestError } from '../rest-client.js';

function toText(data: unknown): { content: [{ type: 'text'; text: string }] } {
return { content: [{ type: 'text', text: JSON.stringify(data) }] };
}

interface ProjectItem {
id: string;
title: string;
parentId: string | null;
description: string | null;
viewMode: 'list' | 'board';
icon: string | null;
color: string | null;
order: number;
}

interface TaskItem {
id: string;
projectId?: string | null;
}

function pickProject(p: ProjectItem): ProjectItem {
return {
id: p.id,
title: p.title,
parentId: p.parentId ?? null,
description: p.description ?? null,
viewMode: p.viewMode,
icon: p.icon ?? null,
color: p.color ?? null,
order: p.order,
};
}

export function registerProjectTools(server: McpServer, client: AlfyRestClient): void {
server.registerTool(
'list_projects',
{
description: 'List all projects as a flat list (id, title, parentId, description, viewMode, icon, color, order)',
inputSchema: {},
},
async () => {
const projects = await client.get<ProjectItem[]>('/projects');
return toText(projects.map(pickProject));
},
);

server.registerTool(
'create_project',
{
description: 'Create a project. Nested via parentId.',
inputSchema: {
title: z.string().min(1).describe('Project title'),
description: z.string().optional().describe('Project description'),
parentId: z.string().uuid().optional().describe('Parent project UUID'),
viewMode: z.enum(['list', 'board']).optional().describe('View mode (default: list)'),
icon: z.string().optional().describe('Icon name'),
color: z.string().optional().describe('Color hex, e.g. #ff0000'),
},
},
async (args) => {
const body: Record<string, unknown> = { title: args.title };
if (args.description !== undefined) body['description'] = args.description;
if (args.parentId !== undefined) body['parentId'] = args.parentId;
if (args.viewMode !== undefined) body['viewMode'] = args.viewMode;
if (args.icon !== undefined) body['icon'] = args.icon;
if (args.color !== undefined) body['color'] = args.color;
const created = await client.post<ProjectItem>('/projects', body);
return toText(pickProject(created));
},
);

server.registerTool(
'update_project',
{
description: 'Update a project. Pass null to clear parentId, description, icon, or color.',
inputSchema: {
id: z.string().uuid().describe('Project UUID'),
title: z.string().min(1).optional().describe('New title'),
description: z.string().nullable().optional().describe('New description, or null to clear'),
parentId: z.string().uuid().nullable().optional().describe('New parent UUID, or null to make root'),
viewMode: z.enum(['list', 'board']).optional().describe('View mode'),
icon: z.string().nullable().optional().describe('Icon name, or null to clear'),
color: z.string().nullable().optional().describe('Color hex, or null to clear'),
},
},
async ({ id, ...rest }) => {
const body: Record<string, unknown> = {};
for (const [k, v] of Object.entries(rest)) {
if (v !== undefined) body[k] = v;
}
const updated = await client.patch<ProjectItem>(`/projects/${id}`, body);
return toText(pickProject(updated));
},
);

server.registerTool(
'delete_project',
{
description:
'Delete a project. Requires confirm=true. Refuses with 409 if the project has child projects or tasks — reparent/move them first.',
inputSchema: {
id: z.string().uuid().describe('Project UUID'),
confirm: z.literal(true).describe('Must be true to delete'),
},
},
async ({ id }) => {
const [projects, tasks] = await Promise.all([
client.get<ProjectItem[]>('/projects'),
client.get<TaskItem[]>('/tasks'),
]);
const childCount = projects.filter((p) => p.parentId === id).length;
const taskCount = tasks.filter((t) => t.projectId === id).length;
if (childCount > 0 || taskCount > 0) {
throw new RestError(
409,
`Project has ${childCount} child project(s) and ${taskCount} task(s). Reparent/move them first.`,
);
}
await client.del(`/projects/${id}`);
return toText({ ok: true });
},
);

server.registerTool(
'reorder_projects',
{
description: 'Set project order. orderedIds is the full ordered list of project UUIDs.',
inputSchema: {
orderedIds: z.array(z.string().uuid()).min(1).describe('Project UUIDs in the desired order'),
},
},
async ({ orderedIds }) => {
await client.patch('/projects/reorder', { orderedIds });
return toText({ ok: true });
},
);
}
7 changes: 5 additions & 2 deletions alfy-mcp/src/tools/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,15 +116,18 @@ export function registerTaskTools(server: McpServer, client: AlfyRestClient): vo
durationMinutes: z.number().int().min(1).optional().describe('New duration in minutes'),
location: z.string().optional().describe('New location'),
tags: z.array(z.string()).optional().describe('New tags'),
projectId: z.string().uuid().optional().describe('Move to project UUID'),
columnId: z.string().uuid().optional().describe('Move to board column UUID'),
projectId: z.string().uuid().nullable().optional().describe('Move to project UUID, or null for Inbox'),
columnId: z.string().uuid().nullable().optional().describe('Move to board column UUID, or null to unset'),
},
},
async ({ id, ...rest }) => {
const body: Record<string, unknown> = {};
for (const [k, v] of Object.entries(rest)) {
if (v !== undefined) body[k] = v;
}
if (rest.projectId === null && rest.columnId === undefined) {
body['columnId'] = null;
}
const updated = await client.patch(`/tasks/${id}`, body);
return toText(updated);
},
Expand Down
Loading
Loading