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__/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/evals/mcp-tools/assertions.ts b/evals/mcp-tools/assertions.ts index 856f6092c..595abbde6 100644 --- a/evals/mcp-tools/assertions.ts +++ b/evals/mcp-tools/assertions.ts @@ -178,3 +178,8 @@ 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 }; +} diff --git a/evals/mcp-tools/promptfooconfig.yaml b/evals/mcp-tools/promptfooconfig.yaml index 1641f0401..32cdab8a2 100644 --- a/evals/mcp-tools/promptfooconfig.yaml +++ b/evals/mcp-tools/promptfooconfig.yaml @@ -135,3 +135,10 @@ 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 diff --git a/evals/mcp-tools/tools.ts b/evals/mcp-tools/tools.ts index 7113a9ab4..bc5feae5d 100644 --- a/evals/mcp-tools/tools.ts +++ b/evals/mcp-tools/tools.ts @@ -10,6 +10,8 @@ import { McpSaveMatchResultsBatchInputShape, McpReviewResumeInputShape, McpSaveResumeReviewInputShape, + McpListTasksInputShape, + McpGetTaskInputShape, } from '../../src/models/mcp.schema'; // Same raw shapes route.ts hands the MCP SDK, so the model sees the parameter @@ -25,6 +27,8 @@ const SHAPES: Record = { save_match_results_batch: McpSaveMatchResultsBatchInputShape, review_resume: McpReviewResumeInputShape, save_resume_review: McpSaveResumeReviewInputShape, + list_tasks: McpListTasksInputShape, + get_task: McpGetTaskInputShape, }; 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/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..43341a8ad 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -21,6 +21,10 @@ import { McpAddJobsBatchSchema, McpSaveMatchResultsBatchInputShape, McpSaveMatchResultsBatchSchema, + McpListTasksInputShape, + McpListTasksSchema, + McpGetTaskInputShape, + McpGetTaskSchema, } from "@/models/mcp.schema"; import { handleAddJob } from "@/lib/mcp/tools/addJob"; import { handleAddQuestion } from "@/lib/mcp/tools/addQuestion"; @@ -31,6 +35,9 @@ 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"; function isMcpEnabled(): boolean { const env = process.env.MCP_ENABLED; @@ -131,6 +138,42 @@ 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( "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. +