diff --git a/apps/server/src/http/resume-pdf.test.ts b/apps/server/src/http/resume-pdf.test.ts index aa306d099..6e470777d 100644 --- a/apps/server/src/http/resume-pdf.test.ts +++ b/apps/server/src/http/resume-pdf.test.ts @@ -23,6 +23,7 @@ describe("handleResumePdfDownload", () => { ok: true, resumeId: "resume-1", userId: "user-1", + target: "resume", expiresAt: "2026-06-01T10:10:00.000Z", }); mocks.createResumePdfDownload.mockResolvedValueOnce({ @@ -49,6 +50,7 @@ describe("handleResumePdfDownload", () => { ok: true, resumeId: "resume-1", userId: "user-1", + target: "cover-letter", expiresAt: "2026-06-01T10:10:00.000Z", }); mocks.createResumePdfDownload.mockResolvedValueOnce({ @@ -68,6 +70,62 @@ describe("handleResumePdfDownload", () => { }); }); + it("defaults a legacy token without a target to resume", async () => { + mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({ + ok: true, + resumeId: "resume-1", + userId: "user-1", + expiresAt: "2026-06-01T10:10:00.000Z", + }); + mocks.createResumePdfDownload.mockResolvedValueOnce({ + headers: { "content-disposition": 'attachment; filename="Cover Letter.pdf"' }, + body: new File([], "Cover Letter.pdf", { type: "application/pdf" }), + }); + + await handleResumePdfDownload(new Request("https://example.com/api/resumes/resume-1/pdf?token=legacy"), "resume-1"); + + expect(mocks.createResumePdfDownload).toHaveBeenCalledWith({ + id: "resume-1", + userId: "user-1", + target: "resume", + }); + }); + + it("rejects a cover-letter target for a legacy token without one", async () => { + mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({ + ok: true, + resumeId: "resume-1", + userId: "user-1", + expiresAt: "2026-06-01T10:10:00.000Z", + }); + + const response = await handleResumePdfDownload( + new Request("https://example.com/api/resumes/resume-1/pdf?token=legacy&target=cover-letter"), + "resume-1", + ); + + expect(response.status).toBe(401); + expect(mocks.createResumePdfDownload).not.toHaveBeenCalled(); + }); + + it("rejects a target that differs from the signed token", async () => { + mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({ + ok: true, + resumeId: "resume-1", + userId: "user-1", + target: "resume", + expiresAt: "2026-06-01T10:10:00.000Z", + }); + + const response = await handleResumePdfDownload( + new Request("https://example.com/api/resumes/resume-1/pdf?token=signed&target=cover-letter"), + "resume-1", + ); + + expect(response.status).toBe(401); + expect(mocks.createResumePdfDownload).not.toHaveBeenCalled(); + }); + it("rejects missing, invalid, and expired tokens before rendering", async () => { let response = await handleResumePdfDownload( new Request("https://example.com/api/resumes/resume-1/pdf"), diff --git a/apps/server/src/http/resume-pdf.ts b/apps/server/src/http/resume-pdf.ts index 488666ccf..d04ba5516 100644 --- a/apps/server/src/http/resume-pdf.ts +++ b/apps/server/src/http/resume-pdf.ts @@ -30,9 +30,16 @@ export async function handleResumePdfDownload(request: Request, id: string) { const verification = verifyResumePdfDownloadToken({ resumeId: id, token }); if (!verification.ok) return verification.reason === "expired" ? expiredResponse() : unauthorizedResponse(); + const queryTarget = searchParams.get("target"); + if ( + verification.target + ? queryTarget !== null && queryTarget !== verification.target + : queryTarget && queryTarget !== "resume" + ) + return unauthorizedResponse(); try { - const target = searchParams.get("target") === "cover-letter" ? "cover-letter" : "resume"; + const target = verification.target ?? "resume"; const download = await createResumePdfDownload({ id, userId: verification.userId, target }); return new Response(download.body, { diff --git a/apps/server/src/mcp/server.ts b/apps/server/src/mcp/server.ts index 6cbce3c61..9ad516dd5 100644 --- a/apps/server/src/mcp/server.ts +++ b/apps/server/src/mcp/server.ts @@ -53,7 +53,7 @@ export function createMcpServer(request: Request) { `Read schema at \`resume://_meta/schema\`; read resume JSON via \`resume://{id}\` or \`${MCP_TOOL_NAME.getResume}\`.`, `Apply body edits with JSON Patch through \`${MCP_TOOL_NAME.patchResume}\`.`, `Change name, slug, tags, or public visibility with \`${MCP_TOOL_NAME.updateResume}\` (returns canonical share URL; anonymous access only when \`isPublic\` is true; passwords are managed in the web app only).`, - `Create short-lived authenticated PDF download URLs with \`${MCP_TOOL_NAME.downloadResumePdf}\`.`, + `Create short-lived authenticated PDF download URLs with \`${MCP_TOOL_NAME.downloadResumePdf}\`; set target to \`cover-letter\` to export a visible cover letter separately.`, `Import full ResumeData JSON with \`${MCP_TOOL_NAME.importResume}\`; read saved AI analysis with \`${MCP_TOOL_NAME.getResumeAnalysis}\`.`, ].join(" "), }, diff --git a/docs/guides/using-the-mcp-server.mdx b/docs/guides/using-the-mcp-server.mdx index 8335fb4de..9c91f2bf1 100644 --- a/docs/guides/using-the-mcp-server.mdx +++ b/docs/guides/using-the-mcp-server.mdx @@ -215,6 +215,7 @@ Tool names use canonical unprefixed `snake_case` names. | `list_resume_tags` | List every distinct tag in use across your resumes (sorted) | | `read_resume` | Get the full data of a specific resume by ID | | `get_resume_analysis` | Get the latest saved AI analysis for a resume (from the web app), if any | +| `download_resume_pdf` | Create a 10-minute authenticated PDF download URL. Defaults to the resume; set `target: "cover-letter"` for its visible cover letter | | `create_resume` | Create a new, empty resume with a name and slug. Optionally pre-fill with sample data | | `import_resume` | Create a resume from a full ResumeData JSON export (random name/slug). Large files may exceed client limits | | `duplicate_resume` | Create a copy of an existing resume with a new name and slug | @@ -311,6 +312,8 @@ For a complete workflow and prompt library, see [Managing applications with MCP] - "Make my resume public and give me the share link" - "Lock my finalized resume so it can't be accidentally edited" - "Delete my old draft resume" +- "Download a PDF of my Software Engineer resume" +- "Download the visible cover letter from my Software Engineer resume as a PDF" ### Editing diff --git a/packages/api/src/features/resume/pdf-download-url.test.ts b/packages/api/src/features/resume/pdf-download-url.test.ts index 3f85bb6d6..8b0a42efb 100644 --- a/packages/api/src/features/resume/pdf-download-url.test.ts +++ b/packages/api/src/features/resume/pdf-download-url.test.ts @@ -1,3 +1,4 @@ +import { createHmac } from "node:crypto"; import { describe, expect, it, vi } from "vitest"; vi.mock("@reactive-resume/env/server", () => ({ @@ -43,11 +44,12 @@ describe("resume PDF signed download URLs", () => { ok: true, resumeId: "resume-1", userId: "user-1", + target: "resume", expiresAt: "2026-06-01T10:10:00.000Z", }); }); - it("can include the cover letter target without changing token verification", () => { + it("binds the cover letter target to the signed token", () => { const result = createResumePdfDownloadUrl({ resumeId: "resume-1", userId: "user-1", @@ -65,7 +67,30 @@ describe("resume PDF signed download URLs", () => { token, now: new Date("2026-06-01T10:01:00.000Z"), }), - ).toMatchObject({ ok: true }); + ).toMatchObject({ ok: true, target: "cover-letter" }); + }); + + it("accepts still-valid legacy tokens without a target", () => { + const payload = Buffer.from( + JSON.stringify({ + v: 1, + resumeId: "resume-1", + userId: "user-1", + expiresAt: new Date("2026-06-01T10:10:00.000Z").getTime(), + issuedAt: new Date("2026-06-01T10:00:00.000Z").getTime(), + }), + "utf8", + ).toString("base64url"); + const token = `${payload}.${createHmac("sha256", "test-secret").update(payload).digest("base64url")}`; + + const verification = verifyResumePdfDownloadToken({ + resumeId: "resume-1", + token, + now: new Date("2026-06-01T10:01:00.000Z"), + }); + + expect(verification).toMatchObject({ ok: true }); + expect("target" in verification).toBe(false); }); it("rejects expired, tampered, and mismatched tokens", () => { diff --git a/packages/api/src/features/resume/pdf-download-url.ts b/packages/api/src/features/resume/pdf-download-url.ts index 17f2dd7b7..e8805f6d1 100644 --- a/packages/api/src/features/resume/pdf-download-url.ts +++ b/packages/api/src/features/resume/pdf-download-url.ts @@ -8,6 +8,7 @@ type PdfDownloadTokenPayload = { v: 1; resumeId: string; userId: string; + target?: ResumeExportTarget; expiresAt: number; issuedAt: number; }; @@ -31,6 +32,7 @@ type VerifyResumePdfDownloadTokenResult = ok: true; resumeId: string; userId: string; + target?: ResumeExportTarget; expiresAt: string; } | { @@ -69,6 +71,7 @@ function parsePayload(value: unknown): PdfDownloadTokenPayload | null { if (payload.v !== 1) return null; if (typeof payload.resumeId !== "string" || payload.resumeId.length === 0) return null; if (typeof payload.userId !== "string" || payload.userId.length === 0) return null; + if (payload.target !== undefined && payload.target !== "resume" && payload.target !== "cover-letter") return null; if (typeof payload.expiresAt !== "number" || !Number.isFinite(payload.expiresAt)) return null; if (typeof payload.issuedAt !== "number" || !Number.isFinite(payload.issuedAt)) return null; @@ -88,6 +91,7 @@ export function createResumePdfDownloadUrl({ v: 1, resumeId, userId, + target: target ?? "resume", expiresAt: expiresAt.getTime(), issuedAt: now.getTime(), } satisfies PdfDownloadTokenPayload); @@ -122,6 +126,7 @@ export function verifyResumePdfDownloadToken({ ok: true, resumeId: parsed.resumeId, userId: parsed.userId, + ...(parsed.target ? { target: parsed.target } : {}), expiresAt: new Date(parsed.expiresAt).toISOString(), }; } catch { diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 91b3e1dff..7354d2b59 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -20,6 +20,7 @@ "@reactive-resume/ai": "workspace:*", "@reactive-resume/api": "workspace:*", "@reactive-resume/env": "workspace:*", + "@reactive-resume/resume": "workspace:*", "@reactive-resume/schema": "workspace:*", "zod": "^4.4.3" }, diff --git a/packages/mcp/src/mcp-server-card.test.ts b/packages/mcp/src/mcp-server-card.test.ts index 21a90cd36..9fd378c26 100644 --- a/packages/mcp/src/mcp-server-card.test.ts +++ b/packages/mcp/src/mcp-server-card.test.ts @@ -40,6 +40,8 @@ describe("buildMcpServerCard", () => { expect(tool?.description).toContain("short-lived"); expect(tool?.description).toContain("10 minutes"); expect(tool?.annotations?.readOnlyHint).toBe(true); + const properties = tool?.inputSchema.properties as Record; + expect(properties.target).toMatchObject({ enum: ["resume", "cover-letter"], default: "resume" }); }); it("advertises application tracker tools", () => { diff --git a/packages/mcp/src/tool-meta.ts b/packages/mcp/src/tool-meta.ts index db75658ef..711d8bccd 100644 --- a/packages/mcp/src/tool-meta.ts +++ b/packages/mcp/src/tool-meta.ts @@ -166,12 +166,20 @@ export const TOOL_META = { [T.downloadResumePdf]: { title: "Download Resume PDF", description: [ - "Create a short-lived authenticated URL for downloading a resume as a PDF.", + "Create a short-lived authenticated URL for downloading a resume or its visible cover letter as a PDF.", "The URL expires in 10 minutes and should be used immediately.", - "Returns JSON containing: resumeId, name, downloadUrl, expiresAt, expiresInSeconds, contentType.", + "Set target to `cover-letter` to export the visible cover letter separately; omit it (or use `resume`) for the resume.", + "Returns JSON containing: resumeId, target, name, downloadUrl, expiresAt, expiresInSeconds, contentType.", `Use \`${T.listResumes}\` first to find valid IDs.`, ].join("\n"), - inputSchema: z.object({ id: resumeIdSchema }), + inputSchema: z.object({ + id: resumeIdSchema, + target: z + .enum(["resume", "cover-letter"]) + .optional() + .default("resume") + .describe("Document to export. Default: resume."), + }), annotations: READ_NON_IDEMPOTENT, }, [T.createResume]: { diff --git a/packages/mcp/src/tools.test.ts b/packages/mcp/src/tools.test.ts index 041b95e5e..9be149423 100644 --- a/packages/mcp/src/tools.test.ts +++ b/packages/mcp/src/tools.test.ts @@ -114,9 +114,14 @@ describe("registerTools", () => { expect(tool.config.title).toBe("Download Resume PDF"); expect(clientMock.resume.getById).toHaveBeenCalledWith({ id: "resume-1" }); expect(mocks.resolveUserFromRequestHeaders).toHaveBeenCalledWith(requestHeaders); - expect(mocks.createResumePdfDownloadUrl).toHaveBeenCalledWith({ resumeId: "resume-1", userId: "user-1" }); + expect(mocks.createResumePdfDownloadUrl).toHaveBeenCalledWith({ + resumeId: "resume-1", + userId: "user-1", + target: "resume", + }); expect(payload).toEqual({ resumeId: "resume-1", + target: "resume", name: "Scizor", downloadUrl: "https://example.com/api/resumes/resume-1/pdf?token=signed", expiresAt: "2026-06-01T10:10:00.000Z", @@ -125,6 +130,61 @@ describe("registerTools", () => { }); }); + it("creates a cover-letter PDF URL and reports cover-letter metadata", async () => { + clientMock.resume.getById.mockResolvedValueOnce({ + id: "resume-1", + name: "Scizor", + data: { customSections: [{ type: "cover-letter", hidden: false, items: [{ hidden: false }] }] }, + }); + mocks.resolveUserFromRequestHeaders.mockResolvedValueOnce({ id: "user-1" }); + mocks.createResumePdfDownloadUrl.mockReturnValueOnce({ + url: "https://example.com/api/resumes/resume-1/pdf?token=signed&target=cover-letter", + expiresAt: "2026-06-01T10:10:00.000Z", + expiresInSeconds: 600, + }); + + const { server, registered } = makeFakeServer(); + registerTools(server as never, clientMock as never, new Headers()); + + const tool = registered.find((item) => item.name === "download_resume_pdf")!; + const result = await tool.handler({ id: "resume-1", target: "cover-letter" }); + + expect(mocks.createResumePdfDownloadUrl).toHaveBeenCalledWith({ + resumeId: "resume-1", + userId: "user-1", + target: "cover-letter", + }); + expect(JSON.parse(result.content[0]!.text)).toEqual({ + resumeId: "resume-1", + target: "cover-letter", + name: "Scizor Cover Letter", + downloadUrl: "https://example.com/api/resumes/resume-1/pdf?token=signed&target=cover-letter", + expiresAt: "2026-06-01T10:10:00.000Z", + expiresInSeconds: 600, + contentType: "application/pdf", + }); + }); + + for (const [name, data] of [ + ["missing", { customSections: [] }], + ["hidden", { customSections: [{ type: "cover-letter", hidden: true, items: [{ hidden: false }] }] }], + ] as const) { + it(`does not create a cover-letter URL when the cover letter is ${name}`, async () => { + clientMock.resume.getById.mockResolvedValueOnce({ id: "resume-1", name: "Scizor", data }); + mocks.resolveUserFromRequestHeaders.mockResolvedValueOnce({ id: "user-1" }); + + const { server, registered } = makeFakeServer(); + registerTools(server as never, clientMock as never, new Headers()); + + const tool = registered.find((item) => item.name === "download_resume_pdf")!; + const result = await tool.handler({ id: "resume-1", target: "cover-letter" }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain("No visible cover letter found for this resume."); + expect(mocks.createResumePdfDownloadUrl).not.toHaveBeenCalled(); + }); + } + it("keeps the tool name stable", () => { expect(MCP_TOOL_NAME.downloadResumePdf).toBe("download_resume_pdf"); }); diff --git a/packages/mcp/src/tools.ts b/packages/mcp/src/tools.ts index b3c3d3df4..2f29a3a5f 100644 --- a/packages/mcp/src/tools.ts +++ b/packages/mcp/src/tools.ts @@ -8,6 +8,7 @@ import { Buffer } from "node:buffer"; import { resolveUserFromRequestHeaders } from "@reactive-resume/api/context"; import { createResumePdfDownloadUrl } from "@reactive-resume/api/features/resume/export"; import { env } from "@reactive-resume/env/server"; +import { resumeHasCoverLetter } from "@reactive-resume/resume/export-sections"; import { resumeDataSchema } from "@reactive-resume/schema/resume/data"; import { MCP_TOOL_NAME } from "./mcp-tool-names"; import { TOOL_META } from "./tool-meta"; @@ -157,32 +158,40 @@ export function registerTools(server: McpServer, client: RouterClient { - const resume = await client.resume.getById({ id }); - const user = await resolveUserFromRequestHeaders(requestHeaders); - if (!user) throw new Error("Unauthorized"); + withErrorHandling( + "creating PDF download URL", + async ({ id, target }: { id: string; target?: "resume" | "cover-letter" }) => { + const resume = await client.resume.getById({ id }); + const user = await resolveUserFromRequestHeaders(requestHeaders); + if (!user) throw new Error("Unauthorized"); - const signedUrl = createResumePdfDownloadUrl({ resumeId: id, userId: user.id }); + const documentTarget = target ?? "resume"; + if (documentTarget === "cover-letter" && !resumeHasCoverLetter(resume.data)) + throw new Error("No visible cover letter found for this resume."); - return text( - JSON.stringify( - { - resumeId: id, - name: resume.name, - downloadUrl: signedUrl.url, - expiresAt: signedUrl.expiresAt, - expiresInSeconds: signedUrl.expiresInSeconds, - contentType: "application/pdf", - }, - null, - 2, - ), - ); - }), + const signedUrl = createResumePdfDownloadUrl({ resumeId: id, userId: user.id, target: documentTarget }); + + return text( + JSON.stringify( + { + resumeId: id, + target: documentTarget, + name: documentTarget === "cover-letter" ? `${resume.name} Cover Letter` : resume.name, + downloadUrl: signedUrl.url, + expiresAt: signedUrl.expiresAt, + expiresInSeconds: signedUrl.expiresInSeconds, + contentType: "application/pdf", + }, + null, + 2, + ), + ); + }, + ), ); // ── Create Resume ───────────────────────────────────────────── diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b00e25a53..0b49a9cb4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1018,6 +1018,9 @@ importers: '@reactive-resume/env': specifier: workspace:* version: link:../env + '@reactive-resume/resume': + specifier: workspace:* + version: link:../resume '@reactive-resume/schema': specifier: workspace:* version: link:../schema