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. +