From d31ffb18790fb28c84fae37ca2d33ab3731727e2 Mon Sep 17 00:00:00 2001 From: Andre Schu Date: Thu, 16 Jul 2026 15:20:51 -0500 Subject: [PATCH] fix: tighten MCP and resume privacy boundaries Prevent implicit resume disclosure, require granular MCP scopes, and resolve downloads through owned resume records so external career adapters can operate without profile leakage. Co-authored-by: Cursor --- __tests__/mcpAddJob.spec.ts | 123 +++++------------- __tests__/mcpScopes.spec.ts | 25 ++++ __tests__/ownedResumeFile.spec.ts | 70 ++++++++++ __tests__/saveMatchResult.spec.ts | 9 ++ prisma/schema.prisma | 2 +- src/actions/mcpToken.actions.ts | 3 +- src/app/api/mcp/route.ts | 36 +++-- src/app/api/profile/resume/route.ts | 16 ++- src/components/myjobs/JobDetails.tsx | 2 +- src/components/profile/DownloadFileButton.tsx | 6 +- src/components/profile/ResumeContainer.tsx | 2 +- src/lib/mcp/scopes.ts | 20 +++ src/lib/mcp/tools/addJob.ts | 108 ++++++--------- src/lib/mcp/tools/saveMatchResult.ts | 97 +++++++++----- src/lib/resumes/ownedResumeFile.ts | 43 ++++++ src/models/mcp.schema.ts | 4 + 16 files changed, 354 insertions(+), 212 deletions(-) create mode 100644 __tests__/mcpScopes.spec.ts create mode 100644 __tests__/ownedResumeFile.spec.ts create mode 100644 src/lib/mcp/scopes.ts create mode 100644 src/lib/resumes/ownedResumeFile.ts diff --git a/__tests__/mcpAddJob.spec.ts b/__tests__/mcpAddJob.spec.ts index 2cbdd4ff..8b3a6c63 100644 --- a/__tests__/mcpAddJob.spec.ts +++ b/__tests__/mcpAddJob.spec.ts @@ -1,155 +1,94 @@ import { handleAddJob } from "@/lib/mcp/tools/addJob"; import { createJobFromNames } from "@/lib/jobs/createJobFromNames"; -import { getDefaultResumeForUser } from "@/lib/jobs/getDefaultResumeForUser"; -import { preprocessResume } from "@/lib/ai/tools/preprocessing"; import { checkMcpRateLimit } from "@/lib/mcp/rate-limit"; vi.mock("@/lib/jobs/createJobFromNames", () => ({ createJobFromNames: vi.fn(), })); -vi.mock("@/lib/jobs/getDefaultResumeForUser", () => ({ - getDefaultResumeForUser: vi.fn(), -})); - -vi.mock("@/lib/ai/tools/preprocessing", () => ({ - preprocessResume: vi.fn(), -})); - vi.mock("@/lib/mcp/rate-limit", () => ({ checkMcpRateLimit: vi.fn(() => ({ allowed: true, resetIn: 0 })), })); -const longDescription = "a".repeat(250); -const shortDescription = "Short JD."; - const baseInput = { company: "Acme", jobTitle: "Engineer", - jobDescription: longDescription, + jobDescription: "A substantive fictional description ".repeat(10), + autoMatch: false, }; -describe("handleAddJob match gating", () => { +describe("handleAddJob privacy boundary", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("appends resume text + save_match_result directive when created + substantive + default resume usable", async () => { + it("creates a job without returning default resume content", async () => { (createJobFromNames as any).mockResolvedValue({ created: true, jobId: "job-1", resolutions: [], message: "Created Acme; Created Engineer. Job created (id: job-1).", }); - (getDefaultResumeForUser as any).mockResolvedValue({ id: "resume-1", title: "My Resume" }); - (preprocessResume as any).mockResolvedValue({ - success: true, - data: { normalizedText: "NORMALIZED RESUME TEXT", metadata: {}, isValid: true }, - }); - const result = await handleAddJob(baseInput as any, "user-1", "my-token"); + const result = await handleAddJob(baseInput, "user-1", "my-token"); const text = result.content[0].text; expect(text).toContain("Job created (id: job-1)"); - expect(text).toContain("NORMALIZED RESUME TEXT"); - expect(text).toContain("job-1"); - expect(text).toContain("save_match_result"); - expect(text).toContain("SCORES: match=<0-100> recommendation="); - // Directive requests the four structured sections (richer match body). - expect(text).toContain("## Overall Fit"); - expect(text).toContain("## Key Strengths"); - expect(text).toContain("## Gaps / Risks"); - expect(text).toContain("## Recommendation"); - // Directive threads the resumeId back for save_match_result provenance. - expect(text).toContain('"resumeId": "resume-1"'); - }); - - it("adds a short-description note and no directive when description is under the threshold", async () => { - (createJobFromNames as any).mockResolvedValue({ - created: true, - jobId: "job-1", - resolutions: [], - message: "Created Acme; Created Engineer. Job created (id: job-1).", - }); - - const result = await handleAddJob( - { ...baseInput, jobDescription: shortDescription } as any, - "user-2", - "my-token", - ); - const text = result.content[0].text; - - expect(text).toContain("Description too short to match"); + expect(text).not.toContain("DEFAULT RESUME"); expect(text).not.toContain("save_match_result"); - expect(getDefaultResumeForUser).not.toHaveBeenCalled(); - }); - - it("adds an actionable note when no default resume is set", async () => { - (createJobFromNames as any).mockResolvedValue({ + expect(result.structuredContent).toEqual({ created: true, jobId: "job-1", - resolutions: [], - message: "Created Acme; Created Engineer. Job created (id: job-1).", + duplicateJobId: null, + autoMatchPerformed: false, }); - (getDefaultResumeForUser as any).mockResolvedValue(null); - - const result = await handleAddJob(baseInput as any, "user-3", "my-token"); - const text = result.content[0].text; - - expect(text).toContain("No default resume set"); - expect(text).not.toContain("save_match_result"); - expect(preprocessResume).not.toHaveBeenCalled(); }); - it("adds a distinct note when the default resume exists but fails preprocessing", async () => { + it("keeps autoMatch fail-closed without disclosing a resume", async () => { (createJobFromNames as any).mockResolvedValue({ created: true, jobId: "job-1", resolutions: [], - message: "Created Acme; Created Engineer. Job created (id: job-1).", - }); - (getDefaultResumeForUser as any).mockResolvedValue({ id: "resume-1", title: "My Resume" }); - (preprocessResume as any).mockResolvedValue({ - success: false, - error: { code: "TOO_SHORT", message: "Resume is too short" }, + message: "Job created (id: job-1).", }); - const result = await handleAddJob(baseInput as any, "user-4", "my-token"); - const text = result.content[0].text; + const result = await handleAddJob( + { ...baseInput, autoMatch: true }, + "user-1", + "my-token", + ); - expect(text).toContain("Default resume couldn't be used for matching"); - expect(text).not.toContain("No default resume set"); - expect(text).not.toContain("save_match_result"); + expect(result.content[0].text).toContain("Automatic MCP matching is disabled"); + expect(result.content[0].text).not.toContain("DEFAULT RESUME"); + expect(result.structuredContent.autoMatchPerformed).toBe(false); }); - it("does not offer a match on a duplicate, and explains why", async () => { + it("returns a structured duplicate identifier", async () => { (createJobFromNames as any).mockResolvedValue({ created: false, duplicateOf: { id: "job-existing", title: "Engineer", company: "Acme" }, resolutions: [], - message: 'Duplicate detected — existing job "Engineer" at "Acme" (id: job-existing).', + message: "Duplicate detected.", }); - const result = await handleAddJob(baseInput as any, "user-5", "my-token"); - const text = result.content[0].text; + const result = await handleAddJob(baseInput, "user-1", "my-token"); - expect(text).toContain("Duplicate detected"); - expect(text).toContain("No match offered for duplicates"); - expect(getDefaultResumeForUser).not.toHaveBeenCalled(); - expect(preprocessResume).not.toHaveBeenCalled(); + expect(result.structuredContent).toMatchObject({ + created: false, + duplicateJobId: "job-existing", + }); }); - it("returns a rate-limit message and never creates a job when the limit is exceeded", async () => { + it("returns a structured rate-limit error without creating a job", async () => { (checkMcpRateLimit as any).mockReturnValueOnce({ allowed: false, - resetIn: 60000, + resetIn: 60_000, }); - const result = await handleAddJob(baseInput as any, "user-6", "my-token"); - const text = result.content[0].text; + const result = await handleAddJob(baseInput, "user-1", "my-token"); - expect(text).toContain("Rate limit exceeded"); - expect(text).toContain("60s"); + expect(result.content[0].text).toContain("Rate limit exceeded"); + expect(result.structuredContent.created).toBe(false); expect(createJobFromNames).not.toHaveBeenCalled(); }); }); diff --git a/__tests__/mcpScopes.spec.ts b/__tests__/mcpScopes.spec.ts new file mode 100644 index 00000000..64de6d6e --- /dev/null +++ b/__tests__/mcpScopes.spec.ts @@ -0,0 +1,25 @@ +import { + DEFAULT_MCP_TOKEN_SCOPES, + hasMcpScope, + MCP_SCOPE, +} from "@/lib/mcp/scopes"; + +describe("MCP scopes", () => { + it("keeps resume data out of default write tokens", () => { + expect(DEFAULT_MCP_TOKEN_SCOPES).toEqual([ + "jobs:create", + "matches:write", + "questions:write", + ]); + expect(DEFAULT_MCP_TOKEN_SCOPES).not.toContain(MCP_SCOPE.RESUMES_READ); + expect(DEFAULT_MCP_TOKEN_SCOPES).not.toContain( + MCP_SCOPE.RESUME_REVIEWS_WRITE, + ); + }); + + it("separates job creation from match persistence", () => { + expect(hasMcpScope(["jobs:create"], MCP_SCOPE.JOBS_CREATE)).toBe(true); + expect(hasMcpScope(["jobs:create"], MCP_SCOPE.MATCHES_WRITE)).toBe(false); + expect(hasMcpScope(["matches:write"], MCP_SCOPE.MATCHES_WRITE)).toBe(true); + }); +}); diff --git a/__tests__/ownedResumeFile.spec.ts b/__tests__/ownedResumeFile.spec.ts new file mode 100644 index 00000000..7856a17c --- /dev/null +++ b/__tests__/ownedResumeFile.spec.ts @@ -0,0 +1,70 @@ +import path from "path"; +import prisma from "@/lib/db"; +import { + getOwnedResumeFile, + isPathInsideUploads, +} from "@/lib/resumes/ownedResumeFile"; + +vi.mock("@/lib/db", () => ({ + default: { + resume: { + findFirst: vi.fn(), + }, + }, +})); + +describe("owned resume download lookup", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("queries by resume id and profile owner", async () => { + const filePath = path.resolve("data/files/resumes/safe.pdf"); + (prisma.resume.findFirst as any).mockResolvedValue({ + File: { filePath, fileName: "safe.pdf" }, + }); + + await expect(getOwnedResumeFile("user-1", "resume-1")).resolves.toEqual({ + filePath, + fileName: "safe.pdf", + }); + expect(prisma.resume.findFirst).toHaveBeenCalledWith({ + where: { + id: "resume-1", + profile: { userId: "user-1" }, + }, + select: { + File: { + select: { + filePath: true, + fileName: true, + }, + }, + }, + }); + }); + + it("rejects missing, cross-user, or path-escaped records", async () => { + (prisma.resume.findFirst as any).mockResolvedValue(null); + await expect( + getOwnedResumeFile("user-1", "other-resume"), + ).resolves.toBeNull(); + + (prisma.resume.findFirst as any).mockResolvedValue({ + File: { + filePath: path.resolve("../secret.pdf"), + fileName: "secret.pdf", + }, + }); + await expect( + getOwnedResumeFile("user-1", "resume-1"), + ).resolves.toBeNull(); + }); + + it("accepts only paths inside the configured uploads root", () => { + expect(isPathInsideUploads(path.resolve("data/files/resumes/safe.pdf"))).toBe( + true, + ); + expect(isPathInsideUploads(path.resolve("../secret.pdf"))).toBe(false); + }); +}); diff --git a/__tests__/saveMatchResult.spec.ts b/__tests__/saveMatchResult.spec.ts index 37cce2a5..2703abe4 100644 --- a/__tests__/saveMatchResult.spec.ts +++ b/__tests__/saveMatchResult.spec.ts @@ -65,6 +65,13 @@ describe("handleSaveMatchResult", () => { expect(result.content[0].text).toContain("Match saved for job job-1"); expect(result.content[0].text).toContain("good match"); expect(result.content[0].text).toContain("78"); + expect(result.structuredContent).toMatchObject({ + saved: true, + jobId: "job-1", + score: 78, + recommendation: "good match", + errorCode: null, + }); }); it("returns a parse-error message when matchText has no SCORES line", async () => { @@ -75,6 +82,7 @@ describe("handleSaveMatchResult", () => { ); expect(result.content[0].text).toContain("Could not parse a SCORES line"); + expect(result.structuredContent.errorCode).toBe("invalid_match_format"); expect(prisma.job.update).not.toHaveBeenCalled(); }); @@ -90,6 +98,7 @@ describe("handleSaveMatchResult", () => { expect(result.content[0].text).toBe( "Job not found, not owned by this token's user, or not eligible for a match via MCP.", ); + expect(result.structuredContent.errorCode).toBe("not_found_or_forbidden"); }); it("overwrites a prior match (last-write-wins, no pre-check)", async () => { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d3dcbb44..0ca08d87 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -510,7 +510,7 @@ model McpAccessToken { name String tokenHash String @unique tokenPrefix String - scopes String // JSON array string; new tokens are always ["jobs:write","questions:write","resume:write"] + scopes String // JSON array; resume scopes require separate explicit grants expiresAt DateTime lastUsedAt DateTime? createdAt DateTime @default(now()) diff --git a/src/actions/mcpToken.actions.ts b/src/actions/mcpToken.actions.ts index c3af178f..aea964c3 100644 --- a/src/actions/mcpToken.actions.ts +++ b/src/actions/mcpToken.actions.ts @@ -5,6 +5,7 @@ import { getCurrentUser } from "@/utils/user.utils"; import { handleError } from "@/lib/utils"; import { generateToken } from "@/lib/mcp/tokens"; import { APP_CONSTANTS } from "@/lib/constants"; +import { DEFAULT_MCP_TOKEN_SCOPES } from "@/lib/mcp/scopes"; export interface PublicTokenMeta { id: string; @@ -44,7 +45,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(DEFAULT_MCP_TOKEN_SCOPES), expiresAt, }, }); diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index b483de44..595463c2 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -18,6 +18,7 @@ import { handleAddQuestion } from "@/lib/mcp/tools/addQuestion"; import { handleSaveMatchResult } from "@/lib/mcp/tools/saveMatchResult"; import { handleReviewResume } from "@/lib/mcp/tools/reviewResume"; import { handleSaveResumeReview } from "@/lib/mcp/tools/saveResumeReview"; +import { hasMcpScope, MCP_SCOPE } from "@/lib/mcp/scopes"; function isMcpEnabled(): boolean { const env = process.env.MCP_ENABLED; @@ -49,12 +50,12 @@ async function handler(req: Request): Promise { "Add a job application to JobSync. Resolves or creates company, job title, location, and source by name. Returns a transparency report of what was matched vs. created.", McpAddJobInputShape, async (rawInput) => { - if (!auth.scopes.includes("jobs:write")) { + if (!hasMcpScope(auth.scopes, MCP_SCOPE.JOBS_CREATE)) { return { content: [ { type: "text" as const, - text: "Insufficient scope. Required: jobs:write", + text: "Insufficient scope. Required: jobs:create", }, ], }; @@ -77,7 +78,7 @@ async function handler(req: Request): Promise { "Add an entry to the Question Bank. Resolves or creates tags by name. Returns a transparency report of what was matched vs. created.", McpAddQuestionInputShape, async (rawInput) => { - if (!auth.scopes.includes("questions:write")) { + if (!hasMcpScope(auth.scopes, MCP_SCOPE.QUESTIONS_WRITE)) { return { content: [ { @@ -105,12 +106,12 @@ async function handler(req: Request): Promise { "Persist a job-fit match analysis (produced by you, the agent) against a job previously created with add_job. Call this after add_job hands you a match directive.", McpSaveMatchResultInputShape, async (rawInput) => { - if (!auth.scopes.includes("jobs:write")) { + if (!hasMcpScope(auth.scopes, MCP_SCOPE.MATCHES_WRITE)) { return { content: [ { type: "text" as const, - text: "Insufficient scope. Required: jobs:write", + text: "Insufficient scope. Required: matches:write", }, ], }; @@ -128,16 +129,21 @@ async function handler(req: Request): Promise { }, ); - // No scope gate, intentionally — unlike the tools above, review_resume and - // save_resume_review have no `resume:write` check. Tokens created before - // this feature shipped don't have that scope and must keep working (see - // docs/plans/feature-mcp-resume-review.md, "Locked decisions"). Do not add - // one without also backfilling existing tokens. server.tool( "review_resume", "Fetch the user's default resume so you can review it. Returns the normalized resume text plus a directive — produce the review yourself, then call save_resume_review with the result.", McpReviewResumeInputShape, async () => { + if (!hasMcpScope(auth.scopes, MCP_SCOPE.RESUMES_READ)) { + return { + content: [ + { + type: "text" as const, + text: "Insufficient scope. Required: resumes:read", + }, + ], + }; + } return handleReviewResume(userId); }, ); @@ -147,6 +153,16 @@ async function handler(req: Request): Promise { "Persist a resume review (produced by you, the agent) against the resume previously handed to you by review_resume. Call this after review_resume hands you a review directive.", McpSaveResumeReviewInputShape, async (rawInput) => { + if (!hasMcpScope(auth.scopes, MCP_SCOPE.RESUME_REVIEWS_WRITE)) { + return { + content: [ + { + type: "text" as const, + text: "Insufficient scope. Required: resume-reviews:write", + }, + ], + }; + } const parsed = McpSaveResumeReviewSchema.safeParse(rawInput); if (!parsed.success) { const issues = parsed.error.issues.map((i) => i.message).join("; "); diff --git a/src/app/api/profile/resume/route.ts b/src/app/api/profile/resume/route.ts index 55cb0777..ff47dff4 100644 --- a/src/app/api/profile/resume/route.ts +++ b/src/app/api/profile/resume/route.ts @@ -11,6 +11,7 @@ import fs from "fs"; import { getTimestampedFileName } from "@/lib/utils"; import { APP_CONSTANTS } from "@/lib/constants"; import { PDF_MAGIC, ZIP_MAGIC } from "@/lib/ai/import/extract-text"; +import { getOwnedResumeFile } from "@/lib/resumes/ownedResumeFile"; const ALLOWED_MIME = new Set(APP_CONSTANTS.RESUME_ALLOWED_MIME_TYPES); @@ -118,22 +119,27 @@ export const GET = async (req: NextRequest) => { } const { searchParams } = new URL(req.url); - const filePath = searchParams.get("filePath"); + const resumeId = searchParams.get("resumeId"); - if (!filePath) { + if (!resumeId) { return NextResponse.json( - { error: "File path is required" }, + { error: "Resume ID is required" }, { status: 400 } ); } - const fullFilePath = path.join(filePath); + const ownedFile = await getOwnedResumeFile(userId!, resumeId); + if (!ownedFile) { + return NextResponse.json({ error: "File not found" }, { status: 404 }); + } + + const fullFilePath = ownedFile.filePath; if (!fs.existsSync(fullFilePath)) { return NextResponse.json({ error: "File not found" }, { status: 404 }); } const fileType = path.extname(fullFilePath).toLowerCase(); - const fileName = path.basename(fullFilePath); + const fileName = ownedFile.fileName; let contentType; diff --git a/src/components/myjobs/JobDetails.tsx b/src/components/myjobs/JobDetails.tsx index 8f8f7486..69d97df3 100644 --- a/src/components/myjobs/JobDetails.tsx +++ b/src/components/myjobs/JobDetails.tsx @@ -269,7 +269,7 @@ function JobDetails({ )} {job?.Resume && job?.Resume?.File && job.Resume?.File?.filePath ? DownloadFileButton( - job?.Resume?.File?.filePath, + job.Resume.id!, job?.Resume?.title, job?.Resume?.File?.fileName, ) diff --git a/src/components/profile/DownloadFileButton.tsx b/src/components/profile/DownloadFileButton.tsx index 642a58aa..24b02d7c 100644 --- a/src/components/profile/DownloadFileButton.tsx +++ b/src/components/profile/DownloadFileButton.tsx @@ -1,13 +1,13 @@ import { Paperclip } from "lucide-react"; export function DownloadFileButton( - filePath: any, + resumeId: string, fileTitle: string, fileName: string ) { const handleDownload = async () => { const response = await fetch( - `/api/profile/resume?filePath=${encodeURIComponent(filePath)}`, + `/api/profile/resume?resumeId=${encodeURIComponent(resumeId)}`, { method: "GET", headers: { @@ -21,7 +21,7 @@ export function DownloadFileButton( const url = window.URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; - link.download = filePath.split("/").pop(); // Get the file name + link.download = fileName; link.target = "_blank"; link.click(); window.URL.revokeObjectURL(url); // Clean up diff --git a/src/components/profile/ResumeContainer.tsx b/src/components/profile/ResumeContainer.tsx index 1ab86554..a02c4511 100644 --- a/src/components/profile/ResumeContainer.tsx +++ b/src/components/profile/ResumeContainer.tsx @@ -806,7 +806,7 @@ function ResumeContainer({ {resume.FileId && resume.File?.filePath ? DownloadFileButton( - resume.File?.filePath, + resume.id!, title, resume.File?.fileName, ) diff --git a/src/lib/mcp/scopes.ts b/src/lib/mcp/scopes.ts new file mode 100644 index 00000000..15dc9e88 --- /dev/null +++ b/src/lib/mcp/scopes.ts @@ -0,0 +1,20 @@ +export const MCP_SCOPE = { + JOBS_CREATE: "jobs:create", + MATCHES_WRITE: "matches:write", + QUESTIONS_WRITE: "questions:write", + RESUMES_READ: "resumes:read", + RESUME_REVIEWS_WRITE: "resume-reviews:write", +} as const; + +export const DEFAULT_MCP_TOKEN_SCOPES = [ + MCP_SCOPE.JOBS_CREATE, + MCP_SCOPE.MATCHES_WRITE, + MCP_SCOPE.QUESTIONS_WRITE, +] as const; + +export function hasMcpScope( + scopes: readonly string[], + required: string, +): boolean { + return scopes.includes(required); +} diff --git a/src/lib/mcp/tools/addJob.ts b/src/lib/mcp/tools/addJob.ts index 3c072ee6..78b899bf 100644 --- a/src/lib/mcp/tools/addJob.ts +++ b/src/lib/mcp/tools/addJob.ts @@ -1,53 +1,34 @@ import { z } from "zod"; -import { APP_CONSTANTS } from "@/lib/constants"; import { McpAddJobSchema } from "@/models/mcp.schema"; import { createJobFromNames } from "@/lib/jobs/createJobFromNames"; import { checkMcpRateLimit } from "@/lib/mcp/rate-limit"; -import { getDefaultResumeForUser } from "@/lib/jobs/getDefaultResumeForUser"; -import { preprocessResume } from "@/lib/ai/tools/preprocessing"; -function buildMatchDirective( - jobId: string, - resumeId: string, - normalizedResumeText: string, -): string { - return ( - `You just added a job to JobSync (job id: ${jobId}). Produce a job-fit ` + - `match of this job against the user's default resume, shown below.\n\n` + - `DEFAULT RESUME (normalized):\n${normalizedResumeText}\n\n` + - `INSTRUCTIONS:\n` + - `- Analyze fit between the resume above and the job description you just\n` + - ` submitted in this same add_job call.\n` + - `- Use ONLY the resume and that job description. Do not invent employers,\n` + - ` skills, titles, dates, or credentials that are not present in them.\n` + - `- Score honestly. If the resume lacks the information needed to judge fit,\n` + - ` still emit a SCORES line with a low score and a "weak" recommendation and\n` + - ` say what is missing — do NOT fabricate a match.\n` + - `- Produce, in this exact order:\n` + - ` 1. A single first line, exactly:\n` + - ` SCORES: match=<0-100> recommendation=\n` + - ` 2. A markdown body with these four sections, each at least a few\n` + - ` sentences of specific, evidence-based analysis (cite concrete\n` + - ` skills, titles, and years from the resume and job description):\n` + - ` ## Overall Fit\n` + - ` ## Key Strengths\n` + - ` ## Gaps / Risks\n` + - ` ## Recommendation\n` + - `- Then call save_match_result with:\n` + - ` { "jobId": "${jobId}", "resumeId": "${resumeId}", "matchText": "" }` - ); -} +type AddJobResponse = { + content: Array<{ type: "text"; text: string }>; + structuredContent: { + created: boolean; + jobId: string | null; + duplicateJobId: string | null; + autoMatchPerformed: false; + }; +}; export async function handleAddJob( input: z.infer, userId: string, tokenName: string, -): Promise<{ content: Array<{ type: "text"; text: string }> }> { +): Promise { const rateCheck = checkMcpRateLimit(userId); if (!rateCheck.allowed) { const resetSec = Math.ceil(rateCheck.resetIn / 1000); return { content: [{ type: "text", text: `Rate limit exceeded. Try again in ${resetSec}s.` }], + structuredContent: { + created: false, + jobId: null, + duplicateJobId: null, + autoMatchPerformed: false, + }, }; } @@ -61,41 +42,38 @@ export async function handleAddJob( const text = result.message + " No match offered for duplicates — you can match it in the web app."; - return { content: [{ type: "text", text }] }; - } - - if (input.jobDescription.length < APP_CONSTANTS.MCP_MATCH_MIN_DESCRIPTION_LENGTH) { - const text = - result.message + - " Description too short to match (under 200 characters) — the job was still added."; - return { content: [{ type: "text", text }] }; - } - - const resume = await getDefaultResumeForUser(userId); - if (!resume) { - const text = - result.message + - " No default resume set — set one in Profile → Resumes to enable automatic matching."; - return { content: [{ type: "text", text }] }; - } - - const pre = await preprocessResume(resume); - if (!pre.success) { - const text = - result.message + - " Default resume couldn't be used for matching (it may be too short or missing content) — check it in Profile → Resumes."; - return { content: [{ type: "text", text }] }; + return { + content: [{ type: "text", text }], + structuredContent: { + created: false, + jobId: null, + duplicateJobId: result.duplicateOf?.id ?? null, + autoMatchPerformed: false, + }, + }; } - const directive = buildMatchDirective( - result.jobId, - resume.id!, - pre.data.normalizedText, - ); - return { content: [{ type: "text", text: `${result.message}\n\n${directive}` }] }; + const autoMatchMessage = input.autoMatch + ? " Automatic MCP matching is disabled; no resume content was returned." + : ""; + return { + content: [{ type: "text", text: result.message + autoMatchMessage }], + structuredContent: { + created: true, + jobId: result.jobId, + duplicateJobId: null, + autoMatchPerformed: false, + }, + }; } catch (err: any) { return { content: [{ type: "text", text: `Error: ${err?.message ?? "Unknown error"}` }], + structuredContent: { + created: false, + jobId: null, + duplicateJobId: null, + autoMatchPerformed: false, + }, }; } } diff --git a/src/lib/mcp/tools/saveMatchResult.ts b/src/lib/mcp/tools/saveMatchResult.ts index 69f20278..609eef30 100644 --- a/src/lib/mcp/tools/saveMatchResult.ts +++ b/src/lib/mcp/tools/saveMatchResult.ts @@ -6,34 +6,65 @@ import { checkMcpRateLimit } from "@/lib/mcp/rate-limit"; import { parseJobMatch } from "@/lib/ai/jobMatch/parse"; import type { JobMatchData } from "@/models/ai.schemas"; +type SaveMatchResponse = { + content: Array<{ type: "text"; text: string }>; + structuredContent: { + saved: boolean; + jobId: string; + score: number | null; + recommendation: string | null; + errorCode: string | null; + }; +}; + +function matchResponse( + jobId: string, + text: string, + options: { + saved?: boolean; + score?: number; + recommendation?: string; + errorCode?: string; + } = {}, +): SaveMatchResponse { + return { + content: [{ type: "text", text }], + structuredContent: { + saved: options.saved ?? false, + jobId, + score: options.score ?? null, + recommendation: options.recommendation ?? null, + errorCode: options.errorCode ?? null, + }, + }; +} + export async function handleSaveMatchResult( input: z.infer, userId: string, tokenName: string, -): Promise<{ content: Array<{ type: "text"; text: string }> }> { +): Promise { // Shares add_job's bucket by design. At the exact window boundary a match can // be computed but rejected here; accepted — the user can retry or match // in-app, and nothing is left in a corrupt state. const rateCheck = checkMcpRateLimit(userId); if (!rateCheck.allowed) { const resetSec = Math.ceil(rateCheck.resetIn / 1000); - return { - content: [{ type: "text", text: `Rate limit exceeded. Try again in ${resetSec}s.` }], - }; + return matchResponse( + input.jobId, + `Rate limit exceeded. Try again in ${resetSec}s.`, + { errorCode: "rate_limited" }, + ); } const parsed = parseJobMatch(input.matchText); if (!parsed.scores) { - return { - content: [ - { - type: "text", - text: - "Could not parse a SCORES line. Include a leading 'SCORES: " + - "match=<0-100> recommendation=' line.", - }, - ], - }; + return matchResponse( + input.jobId, + "Could not parse a SCORES line. Include a leading 'SCORES: " + + "match=<0-100> recommendation=' line.", + { errorCode: "invalid_match_format" }, + ); } // Provenance needs only id + title, so avoid the full resume graph that @@ -83,26 +114,26 @@ export async function handleSaveMatchResult( }); } catch (error: any) { if (error?.code === "P2025") { - return { - content: [ - { - type: "text", - text: "Job not found, not owned by this token's user, or not eligible for a match via MCP.", - }, - ], - }; + return matchResponse( + input.jobId, + "Job not found, not owned by this token's user, or not eligible for a match via MCP.", + { errorCode: "not_found_or_forbidden" }, + ); } - return { - content: [{ type: "text", text: `Error: ${error?.message ?? "Unknown error"}` }], - }; + return matchResponse( + input.jobId, + `Error: ${error?.message ?? "Unknown error"}`, + { errorCode: "internal_error" }, + ); } - return { - content: [ - { - type: "text", - text: `Match saved for job ${input.jobId}: ${parsed.scores.recommendation} (score ${parsed.scores.matchScore}).`, - }, - ], - }; + return matchResponse( + input.jobId, + `Match saved for job ${input.jobId}: ${parsed.scores.recommendation} (score ${parsed.scores.matchScore}).`, + { + saved: true, + score: parsed.scores.matchScore, + recommendation: parsed.scores.recommendation, + }, + ); } diff --git a/src/lib/resumes/ownedResumeFile.ts b/src/lib/resumes/ownedResumeFile.ts new file mode 100644 index 00000000..2abbdd02 --- /dev/null +++ b/src/lib/resumes/ownedResumeFile.ts @@ -0,0 +1,43 @@ +import path from "path"; +import prisma from "@/lib/db"; +import { APP_CONSTANTS } from "@/lib/constants"; + +export type OwnedResumeFile = { + filePath: string; + fileName: string; +}; + +export function isPathInsideUploads(filePath: string): boolean { + const resolvedPath = path.resolve(filePath); + const uploadsRoot = path.resolve(APP_CONSTANTS.UPLOADS_DIR); + return ( + resolvedPath === uploadsRoot || + resolvedPath.startsWith(uploadsRoot + path.sep) + ); +} + +export async function getOwnedResumeFile( + userId: string, + resumeId: string, +): Promise { + const resume = await prisma.resume.findFirst({ + where: { + id: resumeId, + profile: { userId }, + }, + select: { + File: { + select: { + filePath: true, + fileName: true, + }, + }, + }, + }); + + const file = resume?.File; + if (!file?.filePath || !file.fileName || !isPathInsideUploads(file.filePath)) { + return null; + } + return { filePath: file.filePath, fileName: file.fileName }; +} diff --git a/src/models/mcp.schema.ts b/src/models/mcp.schema.ts index 8bca74a9..0952efd3 100644 --- a/src/models/mcp.schema.ts +++ b/src/models/mcp.schema.ts @@ -19,11 +19,15 @@ export const McpAddJobInputShape = { salaryRange: z.string().optional().describe("Salary range as a free-form string, e.g. '$120k–$150k' or '100,000 CAD'"), tags: z.array(z.string()).optional().describe("Skills required for the job (max 10 applied, extras are dropped). Tags are created if they don't exist. e.g. ['React', 'TypeScript', 'Node.js']"), allowDuplicate: z.boolean().optional(), + autoMatch: z.boolean().optional().describe( + "Reserved for server-side matching. Defaults to false and never returns resume content.", + ), }; // Full schema with transforms for parsing raw MCP input in the handler export const McpAddJobSchema = z.object({ ...McpAddJobInputShape, + autoMatch: z.boolean().optional().default(false), dueDate: z.string().datetime({ offset: true }).optional().transform((v) => (v ? new Date(v) : undefined)), appliedDate: z.string().datetime({ offset: true }).optional().transform((v) => (v ? new Date(v) : undefined)), });