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 apps/mcp/ALL_TOOLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions apps/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 21 additions & 5 deletions apps/mcp/src/__tests__/api/views-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
});
});

Expand Down
51 changes: 26 additions & 25 deletions apps/mcp/src/__tests__/tools/view-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ function makeClient(overrides: Record<string, any> = {}) {
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),
Expand Down Expand Up @@ -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");
});
});

Expand All @@ -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");
});
});

Expand Down
22 changes: 14 additions & 8 deletions apps/mcp/src/api/views-client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
Attachment,
BulkMoveTasksInput,
MoveTaskPositionInput,
CreateCustomFieldInput,
CreateViewInput,
CustomFieldDefinition,
Expand Down Expand Up @@ -201,21 +202,26 @@ export class PacaAPIViewsClient {
);
}

async bulkMoveTasks(
async moveTaskPosition(
projectId: string,
viewId: string,
input: BulkMoveTasksInput,
taskId: string,
input: MoveTaskPositionInput,
): Promise<void> {
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}`,
{ position: input.position, group_key: input.group_key ?? null },
);
}

async bulkMoveTasks(
projectId: string,
viewId: string,
input: BulkMoveTasksInput,
): Promise<void> {
await this.bulkMoveViewTaskPositions(projectId, viewId, input.items);
}

// ==================== Custom Fields ====================

async listCustomFieldDefinitions(
Expand Down
121 changes: 56 additions & 65 deletions apps/mcp/src/tools/view-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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: {
Expand All @@ -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: {
Expand All @@ -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"],
},
},
];
Expand Down Expand Up @@ -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}`,
},
],
};
Expand Down
Loading
Loading