From b18415cee634df95c3b3e999e532d58b2dc0fe6d Mon Sep 17 00:00:00 2001 From: bfs2021 <101915083+bfs2021@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:15:56 +0800 Subject: [PATCH 1/2] fix(mcp): send the task-position API the fields it reads move_task and bulk_move_tasks sent target_view_id/target_status_id/target_position, which the task-positions endpoint ignores, so it set the source view's position to 0 and the tools still reported success. Option 1 from the issue: move_task now PUTs {position, group_key} to the single-task route, bulk_move_tasks PUTs {items:[{task_id,position,group_key}]} to the bulk route, and both schemas/descriptions say they set a task's order within a view. Sprint/status moves stay with update_task. --- .../src/__tests__/api/views-client.test.ts | 26 +++- .../src/__tests__/tools/view-tools.test.ts | 51 ++++---- apps/mcp/src/api/views-client.ts | 21 ++- apps/mcp/src/tools/view-tools.ts | 121 ++++++++---------- apps/mcp/src/types/index.ts | 18 +-- 5 files changed, 127 insertions(+), 110 deletions(-) diff --git a/apps/mcp/src/__tests__/api/views-client.test.ts b/apps/mcp/src/__tests__/api/views-client.test.ts index 69714c1ba..91bb95722 100644 --- a/apps/mcp/src/__tests__/api/views-client.test.ts +++ b/apps/mcp/src/__tests__/api/views-client.test.ts @@ -220,17 +220,33 @@ describe("PacaAPIViewsClient", () => { }); describe("bulkMoveTasks", () => { - it("calls PUT /api/v1/projects/:id/views/:viewId/task-positions/:taskId", async () => { + it("calls PUT .../task-positions with the API's items body", async () => { const client = new PacaAPIViewsClient(CONFIG); fetchMock.mockResolvedValue(okEnvelope(null)); await client.bulkMoveTasks("p1", "v1", { - task_id: "t1", - target_view_id: "v2", - target_status_id: null, - target_position: 0, + items: [{ task_id: "t1", position: 2, group_key: "todo" }], + }); + expect(fetchMock.mock.calls[0][0]).toContain("/views/v1/task-positions"); + expect(fetchMock.mock.calls[0][1].method).toBe("PUT"); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body).toEqual({ + items: [{ task_id: "t1", position: 2, group_key: "todo" }], + }); + }); + }); + + describe("moveTaskPosition", () => { + it("calls PUT .../task-positions/:taskId with position and group_key", async () => { + const client = new PacaAPIViewsClient(CONFIG); + fetchMock.mockResolvedValue(okEnvelope(null)); + await client.moveTaskPosition("p1", "v1", "t1", { + position: 2, + group_key: "todo", }); expect(fetchMock.mock.calls[0][0]).toContain("/task-positions/t1"); expect(fetchMock.mock.calls[0][1].method).toBe("PUT"); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body).toEqual({ position: 2, group_key: "todo" }); }); }); diff --git a/apps/mcp/src/__tests__/tools/view-tools.test.ts b/apps/mcp/src/__tests__/tools/view-tools.test.ts index e7e90d095..df1ada50b 100644 --- a/apps/mcp/src/__tests__/tools/view-tools.test.ts +++ b/apps/mcp/src/__tests__/tools/view-tools.test.ts @@ -45,6 +45,7 @@ function makeClient(overrides: Record = {}) { listTaskPositions: vi .fn() .mockResolvedValue([{ task_id: "t1", position: 0 }]), + moveTaskPosition: vi.fn().mockResolvedValue(undefined), bulkMoveTasks: vi.fn().mockResolvedValue(undefined), listCustomFieldDefinitions: vi.fn().mockResolvedValue([customField]), createCustomFieldDefinition: vi.fn().mockResolvedValue(customField), @@ -485,28 +486,35 @@ describe("handleViewTool – list_task_positions", () => { // --------------------------------------------------------------------------- describe("handleViewTool – bulk_move_tasks", () => { - it("calls client.bulkMoveTasks with mapped input", async () => { + it("calls the bulk route with the API's items shape", async () => { const client = makeClient(); await handleViewTool( "bulk_move_tasks", - { projectId: "p1", viewId: "v1", taskId: "t1", targetViewId: "v2" }, + { + projectId: "p1", + viewId: "v1", + items: [ + { taskId: "t1", position: 3 }, + { taskId: "t2", position: 4, groupKey: "todo" }, + ], + }, client, ); expect(client.bulkMoveTasks).toHaveBeenCalledWith("p1", "v1", { - task_id: "t1", - target_view_id: "v2", - target_status_id: null, - target_position: undefined, + items: [ + { task_id: "t1", position: 3, group_key: null }, + { task_id: "t2", position: 4, group_key: "todo" }, + ], }); }); - it("includes 'moved successfully' in response", async () => { + it("reports how many positions changed", async () => { const result = await handleViewTool( "bulk_move_tasks", - { projectId: "p1", viewId: "v1", taskId: "t1", targetViewId: "v2" }, + { projectId: "p1", viewId: "v1", items: [{ taskId: "t1", position: 0 }] }, makeClient(), ); - expect(result.content[0].text).toContain("moved successfully"); + expect(result.content[0].text).toContain("Moved 1 task position in view v1"); }); }); @@ -515,34 +523,27 @@ describe("handleViewTool – bulk_move_tasks", () => { // --------------------------------------------------------------------------- describe("handleViewTool – move_task", () => { - it("calls client.bulkMoveTasks (same underlying method as bulk_move_tasks)", async () => { + it("calls the single-task position route", async () => { const client = makeClient(); await handleViewTool( "move_task", - { - projectId: "p1", - viewId: "v1", - taskId: "t1", - targetViewId: "v2", - targetPosition: 3, - }, + { projectId: "p1", viewId: "v1", taskId: "t1", position: 3 }, client, ); - expect(client.bulkMoveTasks).toHaveBeenCalledWith("p1", "v1", { - task_id: "t1", - target_view_id: "v2", - target_status_id: null, - target_position: 3, + expect(client.moveTaskPosition).toHaveBeenCalledWith("p1", "v1", "t1", { + position: 3, + group_key: null, }); + expect(client.bulkMoveTasks).not.toHaveBeenCalled(); }); - it("includes 'moved successfully' in response", async () => { + it("reports the task and position", async () => { const result = await handleViewTool( "move_task", - { projectId: "p1", viewId: "v1", taskId: "t1", targetViewId: "v2" }, + { projectId: "p1", viewId: "v1", taskId: "t1", position: 3 }, makeClient(), ); - expect(result.content[0].text).toContain("moved successfully"); + expect(result.content[0].text).toBe("Moved task t1 to position 3 in view v1"); }); }); diff --git a/apps/mcp/src/api/views-client.ts b/apps/mcp/src/api/views-client.ts index a54e1bdae..b1ee85482 100644 --- a/apps/mcp/src/api/views-client.ts +++ b/apps/mcp/src/api/views-client.ts @@ -1,6 +1,7 @@ import type { Attachment, BulkMoveTasksInput, + MoveTaskPositionInput, CreateCustomFieldInput, CreateViewInput, CustomFieldDefinition, @@ -201,18 +202,26 @@ export class PacaAPIViewsClient { ); } + async moveTaskPosition( + projectId: string, + viewId: string, + taskId: string, + input: MoveTaskPositionInput, + ): Promise { + await this.put( + `/api/v1/projects/${projectId}/views/${viewId}/task-positions/${taskId}`, + { position: input.position, group_key: input.group_key ?? null }, + ); + } + async bulkMoveTasks( projectId: string, viewId: string, input: BulkMoveTasksInput, ): Promise { await this.put( - `/api/v1/projects/${projectId}/views/${viewId}/task-positions/${input.task_id}`, - { - target_view_id: input.target_view_id, - target_status_id: input.target_status_id, - target_position: input.target_position, - }, + `/api/v1/projects/${projectId}/views/${viewId}/task-positions`, + { items: input.items }, ); } diff --git a/apps/mcp/src/tools/view-tools.ts b/apps/mcp/src/tools/view-tools.ts index 7e9012134..8f16bfba9 100644 --- a/apps/mcp/src/tools/view-tools.ts +++ b/apps/mcp/src/tools/view-tools.ts @@ -95,22 +95,26 @@ const ListTaskPositionsSchema = z.object({ viewId: z.string(), }); -const BulkMoveTasksSchema = z.object({ +const MoveTaskSchema = z.object({ projectId: z.string(), viewId: z.string(), taskId: z.string(), - targetViewId: z.string(), - targetStatusId: z.string().nullable().optional(), - targetPosition: z.number().optional(), + position: z.number().min(0), + groupKey: z.string().nullable().optional(), }); -const MoveTaskSchema = z.object({ +const BulkMoveTasksSchema = z.object({ projectId: z.string(), viewId: z.string(), - taskId: z.string(), - targetViewId: z.string(), - targetStatusId: z.string().nullable().optional(), - targetPosition: z.number().optional(), + items: z + .array( + z.object({ + taskId: z.string(), + position: z.number().min(0), + groupKey: z.string().nullable().optional(), + }), + ) + .min(1), }); const ListCustomFieldsSchema = z.object({ @@ -368,7 +372,8 @@ export function getViewTools(): Tool[] { }, { name: "bulk_move_tasks", - description: "Bulk move tasks in a view", + description: + "Set the position of several tasks within one view (no sprint or status change)", inputSchema: { type: "object", properties: { @@ -380,29 +385,33 @@ export function getViewTools(): Tool[] { type: "string", description: "The ID of the view", }, - taskId: { - type: "string", - description: "The ID of the task to move", - }, - targetViewId: { - type: "string", - description: "The target view ID", - }, - targetStatusId: { - type: "string", - description: "The target status ID", - }, - targetPosition: { - type: "number", - description: "The target position", + items: { + type: "array", + description: "The tasks and their new positions in the view", + items: { + type: "object", + properties: { + taskId: { type: "string", description: "The ID of the task" }, + position: { + type: "number", + description: "The zero-based position in the view", + }, + groupKey: { + type: ["string", "null"], + description: "The group key when the view is grouped", + }, + }, + required: ["taskId", "position"], + }, }, }, - required: ["projectId", "viewId", "taskId", "targetViewId"], + required: ["projectId", "viewId", "items"], }, }, { name: "move_task", - description: "Move a task within a view", + description: + "Set a task's position within one view (no sprint or status change)", inputSchema: { type: "object", properties: { @@ -418,20 +427,16 @@ export function getViewTools(): Tool[] { type: "string", description: "The ID of the task", }, - targetViewId: { - type: "string", - description: "The target view ID", - }, - targetStatusId: { - type: "string", - description: "The target status ID", - }, - targetPosition: { + position: { type: "number", - description: "The target position", + description: "The zero-based position in the view", + }, + groupKey: { + type: ["string", "null"], + description: "The group key when the view is grouped", }, }, - required: ["projectId", "viewId", "taskId", "targetViewId"], + required: ["projectId", "viewId", "taskId", "position"], }, }, ]; @@ -807,50 +812,36 @@ export async function handleViewTool( } case "bulk_move_tasks": { - const { - projectId, - viewId, - taskId, - targetViewId, - targetStatusId, - targetPosition, - } = BulkMoveTasksSchema.parse(args); + const { projectId, viewId, items } = BulkMoveTasksSchema.parse(args); await client.bulkMoveTasks(projectId, viewId, { - task_id: taskId, - target_view_id: targetViewId, - target_status_id: targetStatusId ?? null, - target_position: targetPosition, + items: items.map((item) => ({ + task_id: item.taskId, + position: item.position, + group_key: item.groupKey ?? null, + })), }); return { content: [ { type: "text", - text: `Tasks moved successfully`, + text: `Moved ${items.length} task position${items.length === 1 ? "" : "s"} in view ${viewId}`, }, ], }; } case "move_task": { - const { - projectId, - viewId, - taskId, - targetViewId, - targetStatusId, - targetPosition, - } = MoveTaskSchema.parse(args); - await client.bulkMoveTasks(projectId, viewId, { - task_id: taskId, - target_view_id: targetViewId, - target_status_id: targetStatusId ?? null, - target_position: targetPosition, + const { projectId, viewId, taskId, position, groupKey } = + MoveTaskSchema.parse(args); + await client.moveTaskPosition(projectId, viewId, taskId, { + position, + group_key: groupKey ?? null, }); return { content: [ { type: "text", - text: `Task moved successfully`, + text: `Moved task ${taskId} to position ${position} in view ${viewId}`, }, ], }; diff --git a/apps/mcp/src/types/index.ts b/apps/mcp/src/types/index.ts index 8e4d3f97f..159a684cb 100644 --- a/apps/mcp/src/types/index.ts +++ b/apps/mcp/src/types/index.ts @@ -557,17 +557,17 @@ export interface ReorderViewsInput { view_ids: string[]; } -export interface BulkMoveTasksInput { - task_id: string; - target_view_id: string; - target_status_id: string | null; - target_position?: number; +export interface MoveTaskPositionInput { + position: number; + group_key?: string | null; } -export interface MoveTaskInput { - target_view_id: string; - target_status_id: string | null; - target_position?: number; +export interface BulkMoveTasksInput { + items: Array<{ + task_id: string; + position: number; + group_key?: string | null; + }>; } // ==================== Custom Fields ==================== From db7d522805e2cd2ebe2a6650e9c5158b96d88381 Mon Sep 17 00:00:00 2001 From: bfs2021 <101915083+bfs2021@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:42:43 +0800 Subject: [PATCH 2/2] docs(mcp): describe task-position tools and reuse the bulk helper bulkMoveTasks now delegates to bulkMoveViewTaskPositions so the items contract lives in one place, and the setup guide, README and ALL_TOOLS stop calling these tools sprint moves: they set a task's manual position in one view, and sprint/status changes use update_task. --- apps/mcp/ALL_TOOLS.md | 4 ++-- apps/mcp/README.md | 4 ++-- apps/mcp/src/api/views-client.ts | 5 +---- docs/guides/mcp-server-setup.md | 3 ++- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/apps/mcp/ALL_TOOLS.md b/apps/mcp/ALL_TOOLS.md index da6008d4d..400d5c52d 100644 --- a/apps/mcp/ALL_TOOLS.md +++ b/apps/mcp/ALL_TOOLS.md @@ -72,8 +72,8 @@ This document lists all MCP tools implemented for the Paca API server. - `update_view` - Update an existing view - `delete_view` - Delete a view - `list_task_positions` - List task positions in a view -- `bulk_move_tasks` - Bulk move tasks in a view -- `move_task` - Move a task within a view +- `bulk_move_tasks` - Set several tasks' positions within a view +- `move_task` - Set a task's position within a view ### 10. Custom Fields (5 tools) - `list_custom_fields` - List all custom field definitions in a project diff --git a/apps/mcp/README.md b/apps/mcp/README.md index b553d3c2e..30d08df6b 100644 --- a/apps/mcp/README.md +++ b/apps/mcp/README.md @@ -325,8 +325,8 @@ The MCP server provides **81 tools** across **16 categories** for comprehensive - `update_view` - Update an existing view - `delete_view` - Delete a view - `list_task_positions` - List task positions in a view -- `bulk_move_tasks` - Bulk move tasks in a view -- `move_task` - Move a task within a view +- `bulk_move_tasks` - Set several tasks' positions within a view +- `move_task` - Set a task's position within a view ### 🔧 Custom Fields (5 tools) - `list_custom_fields` - List all custom field definitions diff --git a/apps/mcp/src/api/views-client.ts b/apps/mcp/src/api/views-client.ts index b1ee85482..552866641 100644 --- a/apps/mcp/src/api/views-client.ts +++ b/apps/mcp/src/api/views-client.ts @@ -219,10 +219,7 @@ export class PacaAPIViewsClient { viewId: string, input: BulkMoveTasksInput, ): Promise { - await this.put( - `/api/v1/projects/${projectId}/views/${viewId}/task-positions`, - { items: input.items }, - ); + await this.bulkMoveViewTaskPositions(projectId, viewId, input.items); } // ==================== Custom Fields ==================== diff --git a/docs/guides/mcp-server-setup.md b/docs/guides/mcp-server-setup.md index a4c85b3c1..9e3e9a90b 100644 --- a/docs/guides/mcp-server-setup.md +++ b/docs/guides/mcp-server-setup.md @@ -194,7 +194,8 @@ Agent: (uses MCP tools) 2. create_task - Creates "Implement authentication" task 3. create_task - Creates "Set up database" task 4. create_task - Creates "Create user API" task -5. bulk_move_tasks - Moves all tasks to the new sprint +5. update_task - Sets the sprint for each created task (sprintId) +6. bulk_move_tasks - Sets each task's manual position within the sprint's view ``` ### Example 2: Review and Update Task Status