diff --git a/__tests__/mcpAuth.spec.ts b/__tests__/mcpAuth.spec.ts
index 7238075f6..b66c2d45e 100644
--- a/__tests__/mcpAuth.spec.ts
+++ b/__tests__/mcpAuth.spec.ts
@@ -113,6 +113,25 @@ describe("resolveMcpToken", () => {
});
});
+ it("does not add task scopes to an existing token", async () => {
+ (prisma.mcpAccessToken.findUnique as any).mockResolvedValue({
+ id: "legacy-token",
+ userId: "user-1",
+ scopes: JSON.stringify(["jobs:write"]),
+ name: "legacy",
+ expiresAt: new Date(Date.now() + 1000 * 60 * 60),
+ });
+
+ const result = await resolveMcpToken(makeRequest("Bearer jsync_legacy"));
+
+ expect(result).toEqual({
+ ok: true,
+ userId: "user-1",
+ scopes: ["jobs:write"],
+ tokenName: "legacy",
+ });
+ });
+
it("updates lastUsedAt fire-and-forget on a valid token", async () => {
(prisma.mcpAccessToken.findUnique as any).mockResolvedValue({
id: "t-1",
diff --git a/__tests__/mcpScope.spec.ts b/__tests__/mcpScope.spec.ts
new file mode 100644
index 000000000..1e45f93bf
--- /dev/null
+++ b/__tests__/mcpScope.spec.ts
@@ -0,0 +1,15 @@
+import { getMcpScopeError } from "@/lib/mcp/scope";
+
+describe("getMcpScopeError", () => {
+ it("allows a granted scope", () => {
+ expect(getMcpScopeError(["tasks:read"], "tasks:read")).toBeNull();
+ });
+
+ it("returns the required scope when access is missing", () => {
+ expect(getMcpScopeError(["jobs:write"], "tasks:read")).toEqual({
+ content: [
+ { type: "text", text: "Insufficient scope. Required: tasks:read" },
+ ],
+ });
+ });
+});
diff --git a/__tests__/mcpTaskMutations.spec.ts b/__tests__/mcpTaskMutations.spec.ts
new file mode 100644
index 000000000..63e934a05
--- /dev/null
+++ b/__tests__/mcpTaskMutations.spec.ts
@@ -0,0 +1,255 @@
+import { PrismaClient } from "@prisma/client";
+import { checkMcpRateLimit } from "@/lib/mcp/rate-limit";
+import { handleCompleteTask } from "@/lib/mcp/tools/completeTask";
+import { handleCreateTask } from "@/lib/mcp/tools/createTask";
+import { handleUpdateTask } from "@/lib/mcp/tools/updateTask";
+import { plainTextToTiptapHtml } from "@/lib/tasks/description";
+import { createTaskForUser } from "@/lib/tasks/mutations";
+import {
+ McpCompleteTaskSchema,
+ McpCreateTaskSchema,
+ McpUpdateTaskSchema,
+} from "@/models/mcp.schema";
+
+const prisma = new PrismaClient();
+
+vi.mock("@prisma/client", () => {
+ const mPrismaClient = {
+ task: {
+ create: vi.fn(),
+ findFirst: vi.fn(),
+ update: vi.fn(),
+ },
+ activityType: {
+ findFirst: vi.fn(),
+ upsert: vi.fn(),
+ },
+ };
+ return { PrismaClient: vi.fn(function () { return mPrismaClient; }) };
+});
+
+vi.mock("@/lib/mcp/rate-limit", () => ({
+ checkMcpRateLimit: vi.fn(() => ({ allowed: true, resetIn: 0 })),
+}));
+
+const task = {
+ id: "task-1",
+ userId: "user-1",
+ title: "Follow up",
+ description: "
Email & schedule a call
",
+ status: "in-progress",
+ priority: 5,
+ percentComplete: 0,
+ dueDate: null,
+ activityTypeId: "type-1",
+ activityType: { id: "type-1", label: "Networking" },
+ createdAt: new Date("2026-09-18T10:00:00.000Z"),
+ updatedAt: new Date("2026-09-18T11:00:00.000Z"),
+};
+
+function parseResult(result: { content: Array<{ text: string }> }) {
+ return JSON.parse(result.content[0].text);
+}
+
+describe("task MCP mutation schemas", () => {
+ it("applies create defaults", () => {
+ expect(McpCreateTaskSchema.parse({ title: "Follow up" })).toEqual({
+ title: "Follow up",
+ status: "in-progress",
+ priority: 5,
+ percentComplete: 0,
+ dueDate: undefined,
+ });
+ });
+
+ it("rejects past due dates", () => {
+ expect(() =>
+ McpCreateTaskSchema.parse({
+ title: "Follow up",
+ dueDate: "2020-01-01T00:00:00Z",
+ }),
+ ).toThrow("Due date cannot be in the past");
+ });
+
+ it("allows nullable fields to be cleared", () => {
+ expect(
+ McpUpdateTaskSchema.parse({
+ taskId: "task-1",
+ description: null,
+ dueDate: null,
+ activityType: null,
+ }),
+ ).toEqual({
+ taskId: "task-1",
+ description: null,
+ dueDate: null,
+ activityType: null,
+ });
+ });
+
+ it("rejects no-op updates", () => {
+ expect(() => McpUpdateTaskSchema.parse({ taskId: "task-1" })).toThrow(
+ "At least one task field must be changed",
+ );
+ });
+
+ it("validates complete_task ids", () => {
+ expect(() => McpCompleteTaskSchema.parse({ taskId: "" })).toThrow();
+ });
+});
+
+describe("task description conversion", () => {
+ it("escapes plain text into minimal Tiptap paragraphs", () => {
+ expect(plainTextToTiptapHtml('\nNext')).toBe(
+ "<script>"Hi" & goodbye</script>
Next
",
+ );
+ });
+
+ it("preserves explicit null for clearing", () => {
+ expect(plainTextToTiptapHtml(null)).toBeNull();
+ });
+});
+
+describe("task MCP mutation handlers", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ (checkMcpRateLimit as any).mockReturnValue({ allowed: true, resetIn: 0 });
+ (prisma.activityType.findFirst as any).mockResolvedValue({ id: "type-1" });
+ (prisma.activityType.upsert as any).mockResolvedValue({
+ id: "type-1",
+ label: "Networking",
+ value: "networking",
+ createdBy: "user-1",
+ });
+ (prisma.task.findFirst as any).mockResolvedValue({ id: "task-1" });
+ (prisma.task.create as any).mockResolvedValue(task);
+ (prisma.task.update as any).mockResolvedValue(task);
+ });
+
+ it("creates an owned task and resolves activity labels case-insensitively", async () => {
+ const result = await handleCreateTask(
+ McpCreateTaskSchema.parse({
+ title: "Follow up",
+ description: "Email & schedule a call",
+ activityType: "NETWORKING",
+ }),
+ "user-1",
+ );
+
+ expect(prisma.activityType.upsert).toHaveBeenCalledWith({
+ where: {
+ value_createdBy: { value: "networking", createdBy: "user-1" },
+ },
+ update: {},
+ create: {
+ label: "NETWORKING",
+ value: "networking",
+ createdBy: "user-1",
+ },
+ });
+ expect(prisma.task.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ userId: "user-1",
+ activityTypeId: "type-1",
+ description: "Email & schedule a call
",
+ }),
+ }),
+ );
+ expect(parseResult(result).task.description).toBe("Email & schedule a call");
+ });
+
+ it("rejects a cross-user activity type id", async () => {
+ (prisma.activityType.findFirst as any).mockResolvedValue(null);
+
+ await expect(
+ createTaskForUser("user-1", {
+ title: "Follow up",
+ status: "in-progress",
+ priority: 5,
+ percentComplete: 0,
+ activityTypeId: "other-user-type",
+ }),
+ ).rejects.toThrow("Activity type not found");
+ expect(prisma.activityType.findFirst).toHaveBeenCalledWith({
+ where: { id: "other-user-type", createdBy: "user-1" },
+ select: { id: true },
+ });
+ expect(prisma.task.create).not.toHaveBeenCalled();
+ });
+
+ it("clears nullable fields without resolving a new activity type", async () => {
+ await handleUpdateTask(
+ McpUpdateTaskSchema.parse({
+ taskId: "task-1",
+ description: null,
+ dueDate: null,
+ activityType: null,
+ }),
+ "user-1",
+ );
+
+ expect(prisma.task.findFirst).toHaveBeenCalledWith({
+ where: { id: "task-1", userId: "user-1" },
+ select: { id: true },
+ });
+ expect(prisma.task.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { id: "task-1", userId: "user-1" },
+ data: expect.objectContaining({
+ description: null,
+ dueDate: null,
+ activityTypeId: null,
+ }),
+ }),
+ );
+ expect(prisma.activityType.upsert).not.toHaveBeenCalled();
+ });
+
+ it("sets completion to 100 when status becomes complete", async () => {
+ await handleUpdateTask(
+ McpUpdateTaskSchema.parse({ taskId: "task-1", status: "complete" }),
+ "user-1",
+ );
+
+ expect(prisma.task.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: { status: "complete", percentComplete: 100 },
+ }),
+ );
+ });
+
+ it("allows unrelated updates without revalidating an overdue due date", async () => {
+ await handleUpdateTask(
+ McpUpdateTaskSchema.parse({ taskId: "task-1", priority: 9 }),
+ "user-1",
+ );
+
+ expect(prisma.task.update).toHaveBeenCalledWith(
+ expect.objectContaining({ data: { priority: 9 } }),
+ );
+ });
+
+ it("completes status and percentage in one owned update", async () => {
+ await handleCompleteTask({ taskId: "task-1" }, "user-1");
+
+ expect(prisma.task.update).toHaveBeenCalledTimes(1);
+ expect(prisma.task.update).toHaveBeenCalledWith({
+ where: { id: "task-1", userId: "user-1" },
+ data: { status: "complete", percentComplete: 100 },
+ include: { activityType: true },
+ });
+ });
+
+ it("consumes the shared rate limit before writing", async () => {
+ (checkMcpRateLimit as any).mockReturnValue({ allowed: false, resetIn: 1001 });
+
+ const result = await handleCompleteTask({ taskId: "task-1" }, "user-1");
+
+ expect(parseResult(result)).toEqual({
+ error: "rate_limit_exceeded",
+ retryAfterSeconds: 2,
+ });
+ expect(prisma.task.update).not.toHaveBeenCalled();
+ });
+});
diff --git a/__tests__/mcpTaskQueries.spec.ts b/__tests__/mcpTaskQueries.spec.ts
new file mode 100644
index 000000000..fb2941b91
--- /dev/null
+++ b/__tests__/mcpTaskQueries.spec.ts
@@ -0,0 +1,160 @@
+import { PrismaClient } from "@prisma/client";
+import { checkMcpRateLimit } from "@/lib/mcp/rate-limit";
+import { handleGetTask } from "@/lib/mcp/tools/getTask";
+import { handleListTasks } from "@/lib/mcp/tools/listTasks";
+import {
+ McpGetTaskSchema,
+ McpListTasksSchema,
+} from "@/models/mcp.schema";
+
+const prisma = new PrismaClient();
+
+vi.mock("@prisma/client", () => {
+ const mPrismaClient = {
+ task: {
+ findMany: vi.fn(),
+ findFirst: vi.fn(),
+ count: vi.fn(),
+ },
+ };
+ return { PrismaClient: vi.fn(function () { return mPrismaClient; }) };
+});
+
+vi.mock("@/lib/mcp/rate-limit", () => ({
+ checkMcpRateLimit: vi.fn(() => ({ allowed: true, resetIn: 0 })),
+}));
+
+const task = {
+ id: "task-1",
+ userId: "user-1",
+ title: "Prepare follow-up",
+ description: "Email & schedule a call
",
+ status: "in-progress",
+ priority: 7,
+ percentComplete: 25,
+ dueDate: new Date("2026-10-01T15:00:00.000Z"),
+ activityTypeId: "type-1",
+ activityType: { id: "type-1", label: "Networking" },
+ activities: [],
+ createdAt: new Date("2026-09-18T10:00:00.000Z"),
+ updatedAt: new Date("2026-09-18T11:00:00.000Z"),
+};
+
+function parseResult(result: { content: Array<{ text: string }> }) {
+ return JSON.parse(result.content[0].text);
+}
+
+describe("task MCP schemas", () => {
+ it("applies active-task pagination defaults", () => {
+ expect(McpListTasksSchema.parse({})).toEqual({
+ statuses: ["in-progress", "needs-attention"],
+ page: 1,
+ limit: 25,
+ });
+ });
+
+ it("accepts every task status and rejects an excessive page size", () => {
+ expect(
+ McpListTasksSchema.parse({
+ statuses: ["in-progress", "complete", "needs-attention", "cancelled"],
+ }).statuses,
+ ).toHaveLength(4);
+ expect(() => McpListTasksSchema.parse({ limit: 51 })).toThrow();
+ });
+
+ it("requires a task id", () => {
+ expect(() => McpGetTaskSchema.parse({ taskId: "" })).toThrow();
+ });
+});
+
+describe("task MCP query handlers", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ (checkMcpRateLimit as any).mockReturnValue({ allowed: true, resetIn: 0 });
+ (prisma.task.findMany as any).mockResolvedValue([task]);
+ (prisma.task.count as any).mockResolvedValue(26);
+ (prisma.task.findFirst as any).mockResolvedValue(task);
+ });
+
+ it("lists only the caller's filtered tasks with pagination", async () => {
+ const result = await handleListTasks(
+ McpListTasksSchema.parse({
+ activityType: "NETWORKING",
+ search: "follow-up",
+ page: 2,
+ limit: 10,
+ }),
+ "user-1",
+ );
+
+ expect(prisma.task.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({
+ userId: "user-1",
+ status: { in: ["in-progress", "needs-attention"] },
+ activityType: { value: "networking", createdBy: "user-1" },
+ OR: expect.any(Array),
+ }),
+ skip: 10,
+ take: 10,
+ }),
+ );
+ expect(prisma.task.count).toHaveBeenCalledWith({
+ where: expect.objectContaining({ userId: "user-1" }),
+ });
+
+ expect(parseResult(result)).toEqual({
+ tasks: [
+ expect.objectContaining({
+ id: "task-1",
+ description: "Email & schedule a call",
+ dueDate: "2026-10-01T15:00:00.000Z",
+ }),
+ ],
+ pagination: {
+ page: 2,
+ limit: 10,
+ total: 26,
+ totalPages: 3,
+ hasMore: true,
+ },
+ });
+ });
+
+ it("retrieves a task only for the caller", async () => {
+ const result = await handleGetTask({ taskId: "task-1" }, "user-1");
+
+ expect(prisma.task.findFirst).toHaveBeenCalledWith(
+ expect.objectContaining({ where: { id: "task-1", userId: "user-1" } }),
+ );
+ expect(parseResult(result).task).toEqual(
+ expect.objectContaining({ id: "task-1", title: "Prepare follow-up" }),
+ );
+ });
+
+ it("does not reveal another user's task", async () => {
+ (prisma.task.findFirst as any).mockResolvedValue(null);
+
+ const result = await handleGetTask({ taskId: "task-1" }, "user-2");
+
+ expect(parseResult(result)).toEqual({
+ error: "task_not_found",
+ taskId: "task-1",
+ });
+ });
+
+ it("consumes the shared rate limit before querying", async () => {
+ (checkMcpRateLimit as any).mockReturnValue({ allowed: false, resetIn: 2500 });
+
+ const result = await handleListTasks(
+ McpListTasksSchema.parse({}),
+ "user-1",
+ );
+
+ expect(parseResult(result)).toEqual({
+ error: "rate_limit_exceeded",
+ retryAfterSeconds: 3,
+ });
+ expect(prisma.task.findMany).not.toHaveBeenCalled();
+ });
+});
diff --git a/__tests__/mcpTokens.spec.ts b/__tests__/mcpTokens.spec.ts
index 808d3abe4..a2f5f8d1a 100644
--- a/__tests__/mcpTokens.spec.ts
+++ b/__tests__/mcpTokens.spec.ts
@@ -1,5 +1,17 @@
import { createHash } from "crypto";
-import { generateToken, hashToken } from "@/lib/mcp/tokens";
+import { generateToken, hashToken, MCP_DEFAULT_SCOPES } from "@/lib/mcp/tokens";
+
+describe("MCP_DEFAULT_SCOPES", () => {
+ it("grants task read and write access to newly created tokens", () => {
+ expect(MCP_DEFAULT_SCOPES).toEqual([
+ "jobs:write",
+ "questions:write",
+ "resume:write",
+ "tasks:read",
+ "tasks:write",
+ ]);
+ });
+});
describe("hashToken", () => {
it("returns the sha256 hex digest of the input", () => {
diff --git a/__tests__/task.actions.spec.ts b/__tests__/task.actions.spec.ts
index 5b8216e28..951fc5a23 100644
--- a/__tests__/task.actions.spec.ts
+++ b/__tests__/task.actions.spec.ts
@@ -30,6 +30,8 @@ vi.mock("@prisma/client", () => {
},
activityType: {
findMany: vi.fn(),
+ findFirst: vi.fn(),
+ upsert: vi.fn(),
},
};
return { PrismaClient: vi.fn(function() { return mPrismaClient; }) };
@@ -63,6 +65,10 @@ describe("taskActions", () => {
beforeEach(() => {
vi.clearAllMocks();
+ (prisma.task.findFirst as any).mockResolvedValue(mockTask);
+ (prisma.activityType.findFirst as any).mockResolvedValue({
+ id: "activity-type-id",
+ });
});
describe("getTasksList", () => {
@@ -504,7 +510,11 @@ describe("taskActions", () => {
describe("updateTaskStatus", () => {
it("should update task status successfully", async () => {
(getCurrentUser as any).mockResolvedValue(mockUser);
- const updatedTask = { ...mockTask, status: "complete" as const };
+ const updatedTask = {
+ ...mockTask,
+ status: "complete" as const,
+ percentComplete: 100,
+ };
(prisma.task.update as any).mockResolvedValue(updatedTask);
const result = await updateTaskStatus("task-id", "complete");
@@ -520,6 +530,7 @@ describe("taskActions", () => {
},
data: {
status: "complete",
+ percentComplete: 100,
},
include: {
activityType: true,
diff --git a/e2e/mcp-tasks.spec.ts b/e2e/mcp-tasks.spec.ts
new file mode 100644
index 000000000..58b5155b2
--- /dev/null
+++ b/e2e/mcp-tasks.spec.ts
@@ -0,0 +1,130 @@
+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
+import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
+import { test, expect, uniqueName } from "./fixtures";
+
+function resultJson(result: unknown) {
+ const content = (result as { content?: unknown }).content;
+ const text = (content as Array<{ type: string; text?: string }>)
+ .map((item) => item.text ?? "")
+ .join("");
+ return JSON.parse(text);
+}
+
+test("creates and completes a task through the real MCP transport", async ({
+ page,
+ baseURL,
+ cleanup,
+}) => {
+ const tokenName = uniqueName("e2e mcp task token");
+ const taskTitle = uniqueName("MCP task");
+ const activityType = uniqueName("MCP activity type");
+
+ await page.goto("/dashboard/settings");
+ await page.getByText("MCP Access").click();
+ await page.getByRole("button", { name: "Generate" }).click();
+ await page.getByPlaceholder("e.g. Claude Desktop").fill(tokenName);
+ await page.getByRole("button", { name: "Generate" }).click();
+ cleanup.mcpToken(tokenName);
+ cleanup.task(taskTitle);
+ cleanup.activityType(activityType);
+
+ const revealDialog = page.getByRole("dialog", { name: "Token Created" });
+ const token = await revealDialog.getByRole("textbox").inputValue();
+ await page.getByRole("button", { name: "I saved my token" }).click();
+
+ const transport = new StreamableHTTPClientTransport(
+ new URL("/api/mcp", baseURL),
+ { requestInit: { headers: { Authorization: `Bearer ${token}` } } },
+ );
+ const client = new Client({ name: "e2e-task-client", version: "1.0.0" });
+ await client.connect(transport);
+
+ const tools = await client.listTools();
+ expect(tools.tools.map((tool) => tool.name)).toEqual(
+ expect.arrayContaining([
+ "list_tasks",
+ "get_task",
+ "create_task",
+ "update_task",
+ "complete_task",
+ ]),
+ );
+
+ const created = resultJson(
+ await client.callTool({
+ name: "create_task",
+ arguments: {
+ title: taskTitle,
+ description: "Follow up & confirm",
+ priority: 7,
+ dueDate: new Date(Date.now() + 86_400_000).toISOString(),
+ activityType,
+ },
+ }),
+ ).task;
+ expect(created).toEqual(
+ expect.objectContaining({
+ title: taskTitle,
+ description: "Follow up & confirm",
+ status: "in-progress",
+ priority: 7,
+ percentComplete: 0,
+ }),
+ );
+
+ const listed = resultJson(
+ await client.callTool({
+ name: "list_tasks",
+ arguments: { search: taskTitle, activityType },
+ }),
+ );
+ expect(listed.tasks.map((task: { id: string }) => task.id)).toContain(created.id);
+
+ const retrieved = resultJson(
+ await client.callTool({
+ name: "get_task",
+ arguments: { taskId: created.id },
+ }),
+ ).task;
+ expect(retrieved.id).toBe(created.id);
+
+ const updated = resultJson(
+ await client.callTool({
+ name: "update_task",
+ arguments: {
+ taskId: created.id,
+ priority: 9,
+ description: null,
+ dueDate: null,
+ activityType: null,
+ },
+ }),
+ ).task;
+ expect(updated).toEqual(
+ expect.objectContaining({
+ priority: 9,
+ description: "",
+ dueDate: null,
+ activityType: null,
+ }),
+ );
+
+ const completed = resultJson(
+ await client.callTool({
+ name: "complete_task",
+ arguments: { taskId: created.id },
+ }),
+ ).task;
+ expect(completed).toEqual(
+ expect.objectContaining({ status: "complete", percentComplete: 100 }),
+ );
+ await client.close();
+
+ await page.goto("/dashboard/tasks");
+ await page.getByTestId("add-task-btn").waitFor({ state: "visible" });
+ await page.getByRole("button", { name: "Status", exact: true }).click();
+ await page.getByRole("menuitemcheckbox", { name: "Complete" }).click();
+ const row = page.getByRole("row", { name: new RegExp(taskTitle, "i") });
+ await expect(row).toContainText("Complete");
+ await expect(row).toContainText("100%");
+});
diff --git a/evals/mcp-tools/assertions.ts b/evals/mcp-tools/assertions.ts
index 856f6092c..df24e9c29 100644
--- a/evals/mcp-tools/assertions.ts
+++ b/evals/mcp-tools/assertions.ts
@@ -178,3 +178,28 @@ export function assertRoutesToReviewResume(output: unknown): AssertionResult {
const { pass, score, reason } = expectSingle(output, 'review_resume');
return { pass, score, reason };
}
+
+export function assertRoutesToListTasks(output: unknown): AssertionResult {
+ const { pass, score, reason } = expectSingle(output, 'list_tasks');
+ return { pass, score, reason };
+}
+
+export function assertRoutesToCreateTask(output: unknown): AssertionResult {
+ const { pass, score, reason } = expectSingle(output, 'create_task');
+ return { pass, score, reason };
+}
+
+export function assertRoutesToUpdateTask(output: unknown): AssertionResult {
+ const { pass, score, reason } = expectSingle(output, 'update_task');
+ return { pass, score, reason };
+}
+
+export function assertDiscoversTaskBeforeCompleting(output: unknown): AssertionResult {
+ const { pass, score, reason } = expectSingle(output, 'list_tasks');
+ return { pass, score, reason };
+}
+
+export function assertRoutesToCompleteTask(output: unknown): AssertionResult {
+ const { pass, score, reason } = expectSingle(output, 'complete_task');
+ return { pass, score, reason };
+}
diff --git a/evals/mcp-tools/promptfooconfig.yaml b/evals/mcp-tools/promptfooconfig.yaml
index 1641f0401..a27d67325 100644
--- a/evals/mcp-tools/promptfooconfig.yaml
+++ b/evals/mcp-tools/promptfooconfig.yaml
@@ -135,3 +135,38 @@ tests:
assert:
- type: javascript
value: file://./assertions.ts:assertRoutesToReviewResume
+
+ - description: Active-task request - routes to list_tasks
+ vars:
+ userMessage: Show me my active tasks.
+ assert:
+ - type: javascript
+ value: file://./assertions.ts:assertRoutesToListTasks
+
+ - description: Follow-up task request - routes to create_task
+ vars:
+ userMessage: Create a follow-up task called "Email the recruiter" with priority 8.
+ assert:
+ - type: javascript
+ value: file://./assertions.ts:assertRoutesToCreateTask
+
+ - description: Task priority change - routes to update_task
+ vars:
+ userMessage: Change task task-123's priority to 9.
+ assert:
+ - type: javascript
+ value: file://./assertions.ts:assertRoutesToUpdateTask
+
+ - description: Task completion without an id - discovers the task first
+ vars:
+ userMessage: Mark my recruiter follow-up task done.
+ assert:
+ - type: javascript
+ value: file://./assertions.ts:assertDiscoversTaskBeforeCompleting
+
+ - description: Task completion with an id - routes to complete_task
+ vars:
+ userMessage: Mark task task-123 done.
+ assert:
+ - type: javascript
+ value: file://./assertions.ts:assertRoutesToCompleteTask
diff --git a/evals/mcp-tools/tools.ts b/evals/mcp-tools/tools.ts
index 7113a9ab4..65f5b44a7 100644
--- a/evals/mcp-tools/tools.ts
+++ b/evals/mcp-tools/tools.ts
@@ -10,6 +10,11 @@ import {
McpSaveMatchResultsBatchInputShape,
McpReviewResumeInputShape,
McpSaveResumeReviewInputShape,
+ McpListTasksInputShape,
+ McpGetTaskInputShape,
+ McpCreateTaskInputShape,
+ McpUpdateTaskInputShape,
+ McpCompleteTaskInputShape,
} from '../../src/models/mcp.schema';
// Same raw shapes route.ts hands the MCP SDK, so the model sees the parameter
@@ -25,6 +30,11 @@ const SHAPES: Record = {
save_match_results_batch: McpSaveMatchResultsBatchInputShape,
review_resume: McpReviewResumeInputShape,
save_resume_review: McpSaveResumeReviewInputShape,
+ list_tasks: McpListTasksInputShape,
+ get_task: McpGetTaskInputShape,
+ create_task: McpCreateTaskInputShape,
+ update_task: McpUpdateTaskInputShape,
+ complete_task: McpCompleteTaskInputShape,
};
export function getTools() {
diff --git a/src/actions/mcpToken.actions.ts b/src/actions/mcpToken.actions.ts
index 578cfe2fd..90a0388c6 100644
--- a/src/actions/mcpToken.actions.ts
+++ b/src/actions/mcpToken.actions.ts
@@ -3,7 +3,7 @@
import prisma from "@/lib/db";
import { requireUser } from "./shared";
import { handleError } from "@/lib/utils";
-import { generateToken } from "@/lib/mcp/tokens";
+import { generateToken, MCP_DEFAULT_SCOPES } from "@/lib/mcp/tokens";
import { APP_CONSTANTS } from "@/lib/constants";
export interface PublicTokenMeta {
@@ -43,7 +43,7 @@ export async function createMcpToken(input: {
name: input.name.trim(),
tokenHash: hash,
tokenPrefix: prefix,
- scopes: JSON.stringify(["jobs:write", "questions:write", "resume:write"]),
+ scopes: JSON.stringify(MCP_DEFAULT_SCOPES),
expiresAt,
},
});
diff --git a/src/actions/task/mutations.ts b/src/actions/task/mutations.ts
index e8e2b08fe..49effed82 100644
--- a/src/actions/task/mutations.ts
+++ b/src/actions/task/mutations.ts
@@ -4,12 +4,12 @@ import { handleError } from "@/lib/utils";
import { TaskStatus } from "@/models/task.model";
import { AddTaskFormSchema } from "@/models/addTaskForm.schema";
import { z } from "zod";
+import {
+ createTaskForUser,
+ updateTaskForUser,
+} from "@/lib/tasks/mutations";
import { requireUser } from "../shared";
-const WITH_ACTIVITY_TYPE = {
- activityType: true,
-};
-
export const createTask = async (
data: z.infer
): Promise => {
@@ -18,19 +18,7 @@ export const createTask = async (
const validatedData = AddTaskFormSchema.parse(data);
- const task = await prisma.task.create({
- data: {
- title: validatedData.title,
- description: validatedData.description,
- status: validatedData.status,
- priority: validatedData.priority,
- percentComplete: validatedData.percentComplete,
- dueDate: validatedData.dueDate,
- activityTypeId: validatedData.activityTypeId,
- userId: user.id,
- },
- include: WITH_ACTIVITY_TYPE,
- });
+ const task = await createTaskForUser(user.id, validatedData);
return { success: true, data: task };
} catch (error) {
@@ -51,21 +39,14 @@ export const updateTask = async (
const validatedData = AddTaskFormSchema.parse(data);
- const task = await prisma.task.update({
- where: {
- id: data.id,
- userId: user.id,
- },
- data: {
- title: validatedData.title,
- description: validatedData.description,
- status: validatedData.status,
- priority: validatedData.priority,
- percentComplete: validatedData.percentComplete,
- dueDate: validatedData.dueDate,
- activityTypeId: validatedData.activityTypeId,
- },
- include: WITH_ACTIVITY_TYPE,
+ const task = await updateTaskForUser(user.id, data.id, {
+ title: validatedData.title,
+ description: validatedData.description,
+ status: validatedData.status,
+ priority: validatedData.priority,
+ percentComplete: validatedData.percentComplete,
+ dueDate: validatedData.dueDate,
+ activityTypeId: validatedData.activityTypeId,
});
return { success: true, data: task };
@@ -82,16 +63,7 @@ export const updateTaskStatus = async (
try {
const user = await requireUser();
- const task = await prisma.task.update({
- where: {
- id: taskId,
- userId: user.id,
- },
- data: {
- status,
- },
- include: WITH_ACTIVITY_TYPE,
- });
+ const task = await updateTaskForUser(user.id, taskId, { status });
return { success: true, data: task };
} catch (error) {
diff --git a/src/actions/task/queries.ts b/src/actions/task/queries.ts
index 778e32eba..da2d9706b 100644
--- a/src/actions/task/queries.ts
+++ b/src/actions/task/queries.ts
@@ -3,46 +3,9 @@ import prisma from "@/lib/db";
import { handleError } from "@/lib/utils";
import { TaskGroupBy, TaskStatus } from "@/models/task.model";
import { APP_CONSTANTS } from "@/lib/constants";
+import { getTaskForUser, listTasksForUser } from "@/lib/tasks/queries";
import { requireUser } from "../shared";
-const TASK_WITH_ACTIVITIES_INCLUDE = {
- activityType: true,
- activities: {
- select: { id: true },
- },
-};
-
-function getTasksOrderBy(groupBy?: TaskGroupBy) {
- switch (groupBy) {
- case "dueDate":
- return [
- { dueDate: "asc" as const },
- { priority: "desc" as const },
- { createdAt: "desc" as const },
- ];
- case "createdDate":
- return [{ createdAt: "desc" as const }, { priority: "desc" as const }];
- case "updatedDate":
- return [
- { updatedAt: "desc" as const },
- { priority: "desc" as const },
- { createdAt: "desc" as const },
- ];
- case "activityType":
- return [
- { activityType: { label: "asc" as const } },
- { priority: "desc" as const },
- { createdAt: "desc" as const },
- ];
- default:
- return [
- { priority: "desc" as const },
- { createdAt: "desc" as const },
- { updatedAt: "desc" as const },
- ];
- }
-}
-
export const getTasksList = async (
page: number = 1,
limit: number = APP_CONSTANTS.RECORDS_PER_PAGE,
@@ -54,40 +17,14 @@ export const getTasksList = async (
try {
const user = await requireUser();
- const offset = (page - 1) * limit;
-
- const whereClause: any = {
- userId: user.id,
- };
-
- if (filter) {
- whereClause.activityTypeId = filter;
- }
-
- if (statusFilter && statusFilter.length > 0) {
- whereClause.status = { in: statusFilter };
- }
-
- if (search) {
- whereClause.OR = [
- { title: { contains: search } },
- { description: { contains: search } },
- { activityType: { label: { contains: search } } },
- ];
- }
-
- const [data, total] = await Promise.all([
- prisma.task.findMany({
- where: whereClause,
- include: TASK_WITH_ACTIVITIES_INCLUDE,
- orderBy: getTasksOrderBy(groupBy),
- skip: offset,
- take: limit,
- }),
- prisma.task.count({
- where: whereClause,
- }),
- ]);
+ const { data, total } = await listTasksForUser(user.id, {
+ page,
+ limit,
+ activityTypeId: filter,
+ statuses: statusFilter,
+ search,
+ groupBy,
+ });
return {
success: true,
@@ -106,13 +43,7 @@ export const getTaskById = async (
try {
const user = await requireUser();
- const task = await prisma.task.findFirst({
- where: {
- id: taskId,
- userId: user.id,
- },
- include: TASK_WITH_ACTIVITIES_INCLUDE,
- });
+ const task = await getTaskForUser(user.id, taskId);
if (!task) {
return { success: false, message: "Task not found" };
diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts
index 9050e3064..d9f6b222f 100644
--- a/src/app/api/mcp/route.ts
+++ b/src/app/api/mcp/route.ts
@@ -21,6 +21,16 @@ import {
McpAddJobsBatchSchema,
McpSaveMatchResultsBatchInputShape,
McpSaveMatchResultsBatchSchema,
+ McpListTasksInputShape,
+ McpListTasksSchema,
+ McpGetTaskInputShape,
+ McpGetTaskSchema,
+ McpCreateTaskInputShape,
+ McpCreateTaskSchema,
+ McpUpdateTaskInputShape,
+ McpUpdateTaskSchema,
+ McpCompleteTaskInputShape,
+ McpCompleteTaskSchema,
} from "@/models/mcp.schema";
import { handleAddJob } from "@/lib/mcp/tools/addJob";
import { handleAddQuestion } from "@/lib/mcp/tools/addQuestion";
@@ -31,6 +41,12 @@ import { handleFindJob } from "@/lib/mcp/tools/findJob";
import { handleUpdateJob } from "@/lib/mcp/tools/updateJob";
import { handleAddJobsBatch } from "@/lib/mcp/tools/addJobsBatch";
import { handleSaveMatchResultsBatch } from "@/lib/mcp/tools/saveMatchResultsBatch";
+import { handleListTasks } from "@/lib/mcp/tools/listTasks";
+import { handleGetTask } from "@/lib/mcp/tools/getTask";
+import { getMcpScopeError } from "@/lib/mcp/scope";
+import { handleCreateTask } from "@/lib/mcp/tools/createTask";
+import { handleUpdateTask } from "@/lib/mcp/tools/updateTask";
+import { handleCompleteTask } from "@/lib/mcp/tools/completeTask";
function isMcpEnabled(): boolean {
const env = process.env.MCP_ENABLED;
@@ -131,6 +147,96 @@ async function handler(req: Request): Promise {
},
);
+ server.tool(
+ "list_tasks",
+ MCP_TOOL_DESCRIPTIONS.list_tasks,
+ McpListTasksInputShape,
+ async (rawInput) => {
+ const scopeError = getMcpScopeError(auth.scopes, "tasks:read");
+ if (scopeError) return scopeError;
+ const parsed = McpListTasksSchema.safeParse(rawInput);
+ if (!parsed.success) {
+ const issues = parsed.error.issues.map((i) => i.message).join("; ");
+ return {
+ content: [{ type: "text" as const, text: `Validation error: ${issues}` }],
+ };
+ }
+ return handleListTasks(parsed.data, userId);
+ },
+ );
+
+ server.tool(
+ "get_task",
+ MCP_TOOL_DESCRIPTIONS.get_task,
+ McpGetTaskInputShape,
+ async (rawInput) => {
+ const scopeError = getMcpScopeError(auth.scopes, "tasks:read");
+ if (scopeError) return scopeError;
+ const parsed = McpGetTaskSchema.safeParse(rawInput);
+ if (!parsed.success) {
+ const issues = parsed.error.issues.map((i) => i.message).join("; ");
+ return {
+ content: [{ type: "text" as const, text: `Validation error: ${issues}` }],
+ };
+ }
+ return handleGetTask(parsed.data, userId);
+ },
+ );
+
+ server.tool(
+ "create_task",
+ MCP_TOOL_DESCRIPTIONS.create_task,
+ McpCreateTaskInputShape,
+ async (rawInput) => {
+ const scopeError = getMcpScopeError(auth.scopes, "tasks:write");
+ if (scopeError) return scopeError;
+ const parsed = McpCreateTaskSchema.safeParse(rawInput);
+ if (!parsed.success) {
+ const issues = parsed.error.issues.map((i) => i.message).join("; ");
+ return {
+ content: [{ type: "text" as const, text: `Validation error: ${issues}` }],
+ };
+ }
+ return handleCreateTask(parsed.data, userId);
+ },
+ );
+
+ server.tool(
+ "update_task",
+ MCP_TOOL_DESCRIPTIONS.update_task,
+ McpUpdateTaskInputShape,
+ async (rawInput) => {
+ const scopeError = getMcpScopeError(auth.scopes, "tasks:write");
+ if (scopeError) return scopeError;
+ const parsed = McpUpdateTaskSchema.safeParse(rawInput);
+ if (!parsed.success) {
+ const issues = parsed.error.issues.map((i) => i.message).join("; ");
+ return {
+ content: [{ type: "text" as const, text: `Validation error: ${issues}` }],
+ };
+ }
+ return handleUpdateTask(parsed.data, userId);
+ },
+ );
+
+ server.tool(
+ "complete_task",
+ MCP_TOOL_DESCRIPTIONS.complete_task,
+ McpCompleteTaskInputShape,
+ async (rawInput) => {
+ const scopeError = getMcpScopeError(auth.scopes, "tasks:write");
+ if (scopeError) return scopeError;
+ const parsed = McpCompleteTaskSchema.safeParse(rawInput);
+ if (!parsed.success) {
+ const issues = parsed.error.issues.map((i) => i.message).join("; ");
+ return {
+ content: [{ type: "text" as const, text: `Validation error: ${issues}` }],
+ };
+ }
+ return handleCompleteTask(parsed.data, userId);
+ },
+ );
+
server.tool(
"add_question",
MCP_TOOL_DESCRIPTIONS.add_question,
diff --git a/src/components/settings/McpAccessSettings.tsx b/src/components/settings/McpAccessSettings.tsx
index 65166e538..db3104da7 100644
--- a/src/components/settings/McpAccessSettings.tsx
+++ b/src/components/settings/McpAccessSettings.tsx
@@ -229,7 +229,10 @@ export default function McpAccessSettings() {
Personal Access Tokens
- Tokens authenticate external agents to call MCP tools.
+
+ Tokens authenticate external agents to call MCP tools. Older tokens
+ without task scopes must be regenerated to access task tools.
+