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