diff --git a/alfy-bot/src/modules/project/project.controller.ts b/alfy-bot/src/modules/project/project.controller.ts index 6fb5978..0d7ca4c 100644 --- a/alfy-bot/src/modules/project/project.controller.ts +++ b/alfy-bot/src/modules/project/project.controller.ts @@ -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'; @@ -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) {} diff --git a/alfy-mcp/README.md b/alfy-mcp/README.md index c2ceea1..01b9bf6 100644 --- a/alfy-mcp/README.md +++ b/alfy-mcp/README.md @@ -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). @@ -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) | Инструмент | Описание | diff --git a/alfy-mcp/src/server.ts b/alfy-mcp/src/server.ts index 1d31823..db7850b 100644 --- a/alfy-mcp/src/server.ts +++ b/alfy-mcp/src/server.ts @@ -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. @@ -19,6 +20,7 @@ export function createServer(client: AlfyRestClient): McpServer { registerTaskTools(server, client); registerQuestionTools(server, client); registerProgressTools(server, client); + registerProjectTools(server, client); return server; } diff --git a/alfy-mcp/src/tools/projects.ts b/alfy-mcp/src/tools/projects.ts new file mode 100644 index 0000000..d6d91bb --- /dev/null +++ b/alfy-mcp/src/tools/projects.ts @@ -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('/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 = { 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('/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 = {}; + for (const [k, v] of Object.entries(rest)) { + if (v !== undefined) body[k] = v; + } + const updated = await client.patch(`/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('/projects'), + client.get('/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 }); + }, + ); +} diff --git a/alfy-mcp/src/tools/tasks.ts b/alfy-mcp/src/tools/tasks.ts index 5d9dc33..9044a7b 100644 --- a/alfy-mcp/src/tools/tasks.ts +++ b/alfy-mcp/src/tools/tasks.ts @@ -116,8 +116,8 @@ 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 }) => { @@ -125,6 +125,9 @@ export function registerTaskTools(server: McpServer, client: AlfyRestClient): vo 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); }, diff --git a/alfy-mcp/tests/tools/projects.spec.ts b/alfy-mcp/tests/tools/projects.spec.ts new file mode 100644 index 0000000..cfd55fc --- /dev/null +++ b/alfy-mcp/tests/tools/projects.spec.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { AlfyRestClient } from '../../src/rest-client.js'; +import { RestError } from '../../src/rest-client.js'; +import { registerProjectTools } from '../../src/tools/projects.js'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +function makeClient(): Record> { + return { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + del: vi.fn(), + } as unknown as Record>; +} + +type ToolHandler = (args: Record) => Promise<{ content: Array<{ type: string; text: string }> }>; + +function makeServer() { + const tools: Record = {}; + const server = { + registerTool: vi.fn((name: string, config: unknown, handler: ToolHandler) => { + tools[name] = { config, handler }; + }), + _tools: tools, + async callTool(name: string, args: Record) { + if (!tools[name]) throw new Error(`Tool "${name}" not registered`); + return tools[name].handler(args); + }, + }; + return server; +} + +const ID_A = '00000000-0000-4000-8000-000000000001'; +const ID_B = '00000000-0000-4000-8000-000000000002'; +const ID_C = '00000000-0000-4000-8000-000000000003'; +const ID_NEW = '00000000-0000-4000-8000-000000000010'; +const TASK_A = '00000000-0000-4000-8000-000000000101'; + +function project(overrides: Record = {}) { + return { + id: ID_A, + title: 'Work', + parentId: null, + description: 'desc', + viewMode: 'list', + icon: 'star', + color: '#ff0000', + order: 0, + userId: 42, + createdAt: '2026-01-01T00:00:00.000Z', + ...overrides, + }; +} + +describe('project tools', () => { + let client: ReturnType; + let server: ReturnType; + + beforeEach(() => { + client = makeClient(); + server = makeServer(); + registerProjectTools(server as unknown as McpServer, client as unknown as AlfyRestClient); + }); + + describe('list_projects', () => { + it('registers the tool', () => { + expect(server._tools['list_projects']).toBeDefined(); + }); + + it('calls GET /projects', async () => { + client.get.mockResolvedValueOnce([project()]); + await server.callTool('list_projects', {}); + expect(client.get).toHaveBeenCalledWith('/projects'); + }); + + it('returns only the lean fields', async () => { + client.get.mockResolvedValueOnce([project()]); + const result = await server.callTool('list_projects', {}); + const parsed = JSON.parse(result.content[0].text); + expect(parsed).toEqual([{ + id: ID_A, + title: 'Work', + parentId: null, + description: 'desc', + viewMode: 'list', + icon: 'star', + color: '#ff0000', + order: 0, + }]); + expect(parsed[0]).not.toHaveProperty('userId'); + expect(parsed[0]).not.toHaveProperty('createdAt'); + }); + }); + + describe('create_project', () => { + it('calls POST /projects with title', async () => { + client.post.mockResolvedValueOnce(project({ id: ID_NEW, title: 'New' })); + await server.callTool('create_project', { title: 'New' }); + expect(client.post).toHaveBeenCalledWith('/projects', expect.objectContaining({ title: 'New' })); + }); + + it('forwards optional fields', async () => { + client.post.mockResolvedValueOnce(project({ id: ID_NEW, parentId: ID_A })); + await server.callTool('create_project', { + title: 'Child', + parentId: ID_A, + viewMode: 'board', + icon: 'folder', + color: '#00ff00', + description: 'nested', + }); + expect(client.post).toHaveBeenCalledWith('/projects', { + title: 'Child', + parentId: ID_A, + viewMode: 'board', + icon: 'folder', + color: '#00ff00', + description: 'nested', + }); + }); + + it('returns lean created project', async () => { + client.post.mockResolvedValueOnce(project({ id: ID_NEW, title: 'New' })); + const result = await server.callTool('create_project', { title: 'New' }); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.id).toBe(ID_NEW); + expect(parsed.title).toBe('New'); + expect(parsed).not.toHaveProperty('userId'); + }); + }); + + describe('update_project', () => { + it('calls PATCH /projects/:id and omits id from body', async () => { + client.patch.mockResolvedValueOnce(project({ title: 'Renamed' })); + await server.callTool('update_project', { id: ID_A, title: 'Renamed' }); + expect(client.patch).toHaveBeenCalledWith(`/projects/${ID_A}`, { title: 'Renamed' }); + }); + + it('forwards null parentId to detach from parent', async () => { + client.patch.mockResolvedValueOnce(project({ parentId: null })); + await server.callTool('update_project', { id: ID_B, parentId: null }); + expect(client.patch).toHaveBeenCalledWith(`/projects/${ID_B}`, { parentId: null }); + }); + }); + + describe('delete_project', () => { + it('registers the tool', () => { + expect(server._tools['delete_project']).toBeDefined(); + }); + + it('deletes an empty project when confirm=true', async () => { + client.get + .mockResolvedValueOnce([project(), project({ id: ID_B, title: 'Other' })]) + .mockResolvedValueOnce([{ id: TASK_A, projectId: ID_B }]); + client.del.mockResolvedValueOnce(null); + const result = await server.callTool('delete_project', { id: ID_A, confirm: true }); + expect(client.del).toHaveBeenCalledWith(`/projects/${ID_A}`); + expect(JSON.parse(result.content[0].text)).toEqual({ ok: true }); + }); + + it('refuses with 409 when the project has children', async () => { + client.get + .mockResolvedValueOnce([ + project(), + project({ id: ID_B, title: 'Child', parentId: ID_A }), + ]) + .mockResolvedValueOnce([]); + await expect(server.callTool('delete_project', { id: ID_A, confirm: true })) + .rejects.toMatchObject({ status: 409 }); + expect(client.del).not.toHaveBeenCalled(); + }); + + it('refuses with 409 when the project has tasks', async () => { + client.get + .mockResolvedValueOnce([project()]) + .mockResolvedValueOnce([{ id: TASK_A, projectId: ID_A }]); + await expect(server.callTool('delete_project', { id: ID_A, confirm: true })) + .rejects.toBeInstanceOf(RestError); + expect(client.del).not.toHaveBeenCalled(); + }); + }); + + describe('reorder_projects', () => { + it('calls PATCH /projects/reorder with orderedIds', async () => { + client.patch.mockResolvedValueOnce(null); + const result = await server.callTool('reorder_projects', { orderedIds: [ID_B, ID_A, ID_C] }); + expect(client.patch).toHaveBeenCalledWith('/projects/reorder', { orderedIds: [ID_B, ID_A, ID_C] }); + expect(JSON.parse(result.content[0].text)).toEqual({ ok: true }); + }); + }); +}); diff --git a/alfy-mcp/tests/tools/tasks.spec.ts b/alfy-mcp/tests/tools/tasks.spec.ts index bb9f31c..5a424ac 100644 --- a/alfy-mcp/tests/tools/tasks.spec.ts +++ b/alfy-mcp/tests/tools/tasks.spec.ts @@ -158,6 +158,12 @@ describe('task tools', () => { const body = (client.patch as ReturnType).mock.calls[0][1]; expect(body).not.toHaveProperty('id'); }); + + it('sends projectId: null and clears columnId for Inbox', async () => { + client.patch.mockResolvedValueOnce({ id: ID_A, projectId: null, columnId: null }); + await server.callTool('update_task', { id: ID_A, projectId: null }); + expect(client.patch).toHaveBeenCalledWith(`/tasks/${ID_A}`, { projectId: null, columnId: null }); + }); }); describe('complete_task', () => {