From 3d202184ed89d3225fda7712027532425e30b770 Mon Sep 17 00:00:00 2001 From: NefelibateLG <3453510037@qq.com> Date: Sat, 24 Oct 2026 01:39:18 +0800 Subject: [PATCH 1/3] fix(mcp): make move_task/bulk_move_tasks match the task-positions API (closes #610) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit move_task and bulk_move_tasks sent target_view_id/target_status_id/ target_position, which the API's MoveTaskRequest (position, group_key) ignores entirely — so the endpoint upserted position 0 in the source view and the tools reported 'Task moved successfully' without anything moving (see #610). Align both tools with the task-positions API (reorder-only, as agreed in the issue): move_task takes {viewId, taskId, position, groupKey?}, bulk_move_tasks takes an items array and calls the bulk route. Cross- view/sprint/status moves now point to update_task. Tool descriptions and the mcp-server-setup guide example updated accordingly. Co-authored-by: ZCode Scout --- .../src/__tests__/api/views-client.test.ts | 21 +-- .../src/__tests__/tools/view-tools.test.ts | 100 +++++++++---- apps/mcp/src/api/views-client.ts | 23 +-- apps/mcp/src/tools/view-tools.ts | 131 +++++++++--------- apps/mcp/src/types/index.ts | 17 +-- docs/guides/mcp-server-setup.md | 2 +- 6 files changed, 176 insertions(+), 118 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..6cdc2bd83 100644 --- a/apps/mcp/src/__tests__/api/views-client.test.ts +++ b/apps/mcp/src/__tests__/api/views-client.test.ts @@ -219,18 +219,23 @@ describe("PacaAPIViewsClient", () => { }); }); - describe("bulkMoveTasks", () => { - it("calls PUT /api/v1/projects/:id/views/:viewId/task-positions/:taskId", async () => { + describe("moveTaskPosition", () => { + it("calls PUT /api/v1/projects/:id/views/:viewId/task-positions/:taskId with position and group_key", 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, - }); + await client.moveTaskPosition("p1", "v1", "t1", 3, "group-1"); 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: 3, group_key: "group-1" }); + }); + + it("omits group_key when not provided", async () => { + const client = new PacaAPIViewsClient(CONFIG); + fetchMock.mockResolvedValue(okEnvelope(null)); + await client.moveTaskPosition("p1", "v1", "t1", 0); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body).toEqual({ position: 0 }); }); }); diff --git a/apps/mcp/src/__tests__/tools/view-tools.test.ts b/apps/mcp/src/__tests__/tools/view-tools.test.ts index e7e90d095..870d772d6 100644 --- a/apps/mcp/src/__tests__/tools/view-tools.test.ts +++ b/apps/mcp/src/__tests__/tools/view-tools.test.ts @@ -45,7 +45,8 @@ function makeClient(overrides: Record = {}) { listTaskPositions: vi .fn() .mockResolvedValue([{ task_id: "t1", position: 0 }]), - bulkMoveTasks: vi.fn().mockResolvedValue(undefined), + moveTaskPosition: vi.fn().mockResolvedValue(undefined), + bulkMoveViewTaskPositions: vi.fn().mockResolvedValue(undefined), listCustomFieldDefinitions: vi.fn().mockResolvedValue([customField]), createCustomFieldDefinition: vi.fn().mockResolvedValue(customField), getCustomFieldDefinition: vi.fn().mockResolvedValue(customField), @@ -485,28 +486,49 @@ describe("handleViewTool – list_task_positions", () => { // --------------------------------------------------------------------------- describe("handleViewTool – bulk_move_tasks", () => { - it("calls client.bulkMoveTasks with mapped input", async () => { + it("calls client.bulkMoveViewTaskPositions with mapped items", 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: 0 }, + { taskId: "t2", position: 1, groupKey: "group-1" }, + ], + }, client, ); - expect(client.bulkMoveTasks).toHaveBeenCalledWith("p1", "v1", { - task_id: "t1", - target_view_id: "v2", - target_status_id: null, - target_position: undefined, - }); + expect(client.bulkMoveViewTaskPositions).toHaveBeenCalledWith("p1", "v1", [ + { task_id: "t1", position: 0, group_key: undefined }, + { task_id: "t2", position: 1, group_key: "group-1" }, + ]); }); - it("includes 'moved successfully' in response", async () => { + it("rejects an empty items array", async () => { + const client = makeClient(); + await expect( + handleViewTool( + "bulk_move_tasks", + { projectId: "p1", viewId: "v1", items: [] }, + client, + ), + ).rejects.toThrow(); + expect(client.bulkMoveViewTaskPositions).not.toHaveBeenCalled(); + }); + + it("reports how many positions were updated", 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("1 task position updated"); }); }); @@ -515,7 +537,7 @@ describe("handleViewTool – bulk_move_tasks", () => { // --------------------------------------------------------------------------- describe("handleViewTool – move_task", () => { - it("calls client.bulkMoveTasks (same underlying method as bulk_move_tasks)", async () => { + it("calls client.moveTaskPosition with view, task, position, and group key", async () => { const client = makeClient(); await handleViewTool( "move_task", @@ -523,26 +545,56 @@ describe("handleViewTool – move_task", () => { projectId: "p1", viewId: "v1", taskId: "t1", - targetViewId: "v2", - targetPosition: 3, + position: 3, + groupKey: "group-1", }, 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", + 3, + "group-1", + ); + }); + + it("omits the group key argument when not provided", async () => { + const client = makeClient(); + await handleViewTool( + "move_task", + { projectId: "p1", viewId: "v1", taskId: "t1", position: 2 }, + client, + ); + expect(client.moveTaskPosition).toHaveBeenCalledWith( + "p1", + "v1", + "t1", + 2, + undefined, + ); + }); + + it("rejects a missing position", async () => { + const client = makeClient(); + await expect( + handleViewTool( + "move_task", + { projectId: "p1", viewId: "v1", taskId: "t1" }, + client, + ), + ).rejects.toThrow(); + expect(client.moveTaskPosition).not.toHaveBeenCalled(); }); - it("includes 'moved successfully' in response", async () => { + it("reports the new position and that no cross-view move happened", 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).toContain("position set to 3"); + expect(result.content[0].text).toContain("reorder only"); }); }); diff --git a/apps/mcp/src/api/views-client.ts b/apps/mcp/src/api/views-client.ts index a54e1bdae..b369a1e90 100644 --- a/apps/mcp/src/api/views-client.ts +++ b/apps/mcp/src/api/views-client.ts @@ -1,9 +1,9 @@ import type { Attachment, - BulkMoveTasksInput, CreateCustomFieldInput, CreateViewInput, CustomFieldDefinition, + MoveTaskPositionItem, PacaConfig, ReorderViewsInput, SuccessEnvelope, @@ -201,18 +201,23 @@ export class PacaAPIViewsClient { ); } - async bulkMoveTasks( + async moveTaskPosition( projectId: string, viewId: string, - input: BulkMoveTasksInput, + taskId: string, + position: number, + groupKey?: string | null, ): Promise { + // The API's MoveTaskRequest is an upsert of the task's order within this + // view only: it accepts { position, group_key? } and never moves a task + // across views, sprints, or statuses. + const body: { position: number; group_key?: string | null } = { position }; + if (groupKey !== undefined) { + body.group_key = groupKey; + } 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/${taskId}`, + body, ); } diff --git a/apps/mcp/src/tools/view-tools.ts b/apps/mcp/src/tools/view-tools.ts index 7e9012134..b7c21ab6b 100644 --- a/apps/mcp/src/tools/view-tools.ts +++ b/apps/mcp/src/tools/view-tools.ts @@ -95,22 +95,24 @@ const ListTaskPositionsSchema = z.object({ viewId: z.string(), }); +const MoveTaskPositionItemSchema = z.object({ + taskId: z.string(), + position: z.number().min(0), + groupKey: z.string().nullable().optional(), +}); + 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(MoveTaskPositionItemSchema).min(1), }); 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 ListCustomFieldsSchema = z.object({ @@ -368,7 +370,8 @@ export function getViewTools(): Tool[] { }, { name: "bulk_move_tasks", - description: "Bulk move tasks in a view", + description: + "Set the manual order of multiple tasks within one view (reorder only). This never changes a task's view, sprint, or status — use update_task for that. Pass items: [{ taskId, position, groupKey? }].", inputSchema: { type: "object", properties: { @@ -378,31 +381,43 @@ export function getViewTools(): Tool[] { }, viewId: { 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", + description: + "The ID of the view whose ordering is being set (tasks are reordered within this view only)", }, - targetPosition: { - type: "number", - description: "The target position", + items: { + type: "array", + minItems: 1, + items: { + type: "object", + properties: { + taskId: { + type: "string", + description: "The ID of the task", + }, + position: { + type: "number", + description: + "The task's new order position within the view (0-based)", + }, + groupKey: { + type: ["string", "null"], + description: + "Optional grouping key within the view (e.g. a board column); null clears it", + }, + }, + required: ["taskId", "position"], + }, + description: + "The task positions to upsert within this view (1+ entries)", }, }, - required: ["projectId", "viewId", "taskId", "targetViewId"], + required: ["projectId", "viewId", "items"], }, }, { name: "move_task", - description: "Move a task within a view", + description: + "Set a task's order (position) within a view (reorder only). This never moves the task to another view, sprint, or status — use update_task for that.", inputSchema: { type: "object", properties: { @@ -412,26 +427,25 @@ export function getViewTools(): Tool[] { }, viewId: { type: "string", - description: "The ID of the view", + description: + "The ID of the view whose ordering is being set (the task is reordered within this view only)", }, taskId: { 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 task's new order position within the view (0-based)", + }, + groupKey: { + type: ["string", "null"], + description: + "Optional grouping key within the view (e.g. a board column); null clears it", }, }, - required: ["projectId", "viewId", "taskId", "targetViewId"], + required: ["projectId", "viewId", "taskId", "position"], }, }, ]; @@ -807,50 +821,35 @@ export async function handleViewTool( } case "bulk_move_tasks": { - const { + const { projectId, viewId, items } = BulkMoveTasksSchema.parse(args); + await client.bulkMoveViewTaskPositions( projectId, viewId, - taskId, - targetViewId, - targetStatusId, - targetPosition, - } = BulkMoveTasksSchema.parse(args); - await client.bulkMoveTasks(projectId, viewId, { - task_id: taskId, - target_view_id: targetViewId, - target_status_id: targetStatusId ?? null, - target_position: targetPosition, - }); + items.map((item) => ({ + task_id: item.taskId, + position: item.position, + group_key: item.groupKey, + })), + ); return { content: [ { type: "text", - text: `Tasks moved successfully`, + text: `${items.length} task position${items.length === 1 ? "" : "s"} updated in view ${viewId} (reorder only; view, sprint, and status unchanged)`, }, ], }; } 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, groupKey); return { content: [ { type: "text", - text: `Task moved successfully`, + text: `Task ${taskId} position set to ${position} in view ${viewId} (reorder only; view, sprint, and status unchanged)`, }, ], }; diff --git a/apps/mcp/src/types/index.ts b/apps/mcp/src/types/index.ts index 8e4d3f97f..6bf947683 100644 --- a/apps/mcp/src/types/index.ts +++ b/apps/mcp/src/types/index.ts @@ -557,17 +557,14 @@ export interface ReorderViewsInput { view_ids: string[]; } -export interface BulkMoveTasksInput { +// Matches the backend's BulkMoveTaskItem DTO: a task's manual order within a +// single view. Moving a task across views/sprints/statuses is an update_task +// concern, not a task-positions one (see the API's MoveTaskRequest and +// BulkMoveTasksRequest). +export interface MoveTaskPositionItem { task_id: string; - target_view_id: string; - target_status_id: string | null; - target_position?: number; -} - -export interface MoveTaskInput { - target_view_id: string; - target_status_id: string | null; - target_position?: number; + position: number; + group_key?: string | null; } // ==================== Custom Fields ==================== diff --git a/docs/guides/mcp-server-setup.md b/docs/guides/mcp-server-setup.md index a4c85b3c1..d5e77c0d1 100644 --- a/docs/guides/mcp-server-setup.md +++ b/docs/guides/mcp-server-setup.md @@ -194,7 +194,7 @@ 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 - Assigns each task to the new sprint (bulk_move_tasks only reorders tasks within a view; it cannot move tasks between sprints) ``` ### Example 2: Review and Update Task Status From 08caf9da58ff5f1f0a4d23f43e1051f89534550f Mon Sep 17 00:00:00 2001 From: NefelibateLG <3453510037@qq.com> Date: Sat, 24 Oct 2026 01:47:49 +0800 Subject: [PATCH 2/3] test(mcp): cover explicit null group_key clearing in moveTaskPosition --- apps/mcp/src/__tests__/api/views-client.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/mcp/src/__tests__/api/views-client.test.ts b/apps/mcp/src/__tests__/api/views-client.test.ts index 6cdc2bd83..0b2cb0889 100644 --- a/apps/mcp/src/__tests__/api/views-client.test.ts +++ b/apps/mcp/src/__tests__/api/views-client.test.ts @@ -237,6 +237,14 @@ describe("PacaAPIViewsClient", () => { const body = JSON.parse(fetchMock.mock.calls[0][1].body); expect(body).toEqual({ position: 0 }); }); + + it("sends an explicit null group_key to clear the grouping", async () => { + const client = new PacaAPIViewsClient(CONFIG); + fetchMock.mockResolvedValue(okEnvelope(null)); + await client.moveTaskPosition("p1", "v1", "t1", 1, null); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body).toEqual({ position: 1, group_key: null }); + }); }); // --------------------------------------------------------------------------- From 7f34dd7826e1ee83465114277b56323ece35de7c Mon Sep 17 00:00:00 2001 From: NefelibateLG <3453510037@qq.com> Date: Sat, 24 Oct 2026 03:01:26 +0800 Subject: [PATCH 3/3] chore(mcp): drop unused MoveTaskPositionItem import Flagged by biome lint/correctness/noUnusedImports; the type is exported from types/index.ts for external consumers, the client method takes primitives. --- apps/mcp/src/api/views-client.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/mcp/src/api/views-client.ts b/apps/mcp/src/api/views-client.ts index b369a1e90..951bdc03a 100644 --- a/apps/mcp/src/api/views-client.ts +++ b/apps/mcp/src/api/views-client.ts @@ -3,7 +3,6 @@ import type { CreateCustomFieldInput, CreateViewInput, CustomFieldDefinition, - MoveTaskPositionItem, PacaConfig, ReorderViewsInput, SuccessEnvelope,