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)), });