Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions __tests__/mcpAuth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions __tests__/mcpScope.spec.ts
Original file line number Diff line number Diff line change
@@ -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" },
],
});
});
});
160 changes: 160 additions & 0 deletions __tests__/mcpTaskQueries.spec.ts
Original file line number Diff line number Diff line change
@@ -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: "<p>Email &amp; schedule a call</p>",
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();
});
});
14 changes: 13 additions & 1 deletion __tests__/mcpTokens.spec.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down
5 changes: 5 additions & 0 deletions evals/mcp-tools/assertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
7 changes: 7 additions & 0 deletions evals/mcp-tools/promptfooconfig.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions evals/mcp-tools/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,6 +27,8 @@ const SHAPES: Record<string, z.ZodRawShape> = {
save_match_results_batch: McpSaveMatchResultsBatchInputShape,
review_resume: McpReviewResumeInputShape,
save_resume_review: McpSaveResumeReviewInputShape,
list_tasks: McpListTasksInputShape,
get_task: McpGetTaskInputShape,
};

export function getTools() {
Expand Down
4 changes: 2 additions & 2 deletions src/actions/mcpToken.actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
},
});
Expand Down
89 changes: 10 additions & 79 deletions src/actions/task/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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" };
Expand Down
Loading