feat(mcp): add cover-letter PDF downloads (#3304)

* feat(mcp): add cover-letter PDF downloads

* fix(mcp): bind signed PDF targets

* test(mcp): cover unavailable cover letters

* fix(api): accept legacy PDF download targets

* fix(server): limit legacy PDF tokens to resumes

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
This commit is contained in:
ignaciocarre
2026-08-13 23:09:56 +02:00
committed by GitHub
co-authored by Amruth Pillai
parent 45303fb465
commit 6d9ebccc63
12 changed files with 210 additions and 29 deletions
+58
View File
@@ -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"),
+8 -1
View File
@@ -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, {
+1 -1
View File
@@ -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(" "),
},
+3
View File
@@ -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
@@ -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", () => {
@@ -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 {
+1
View File
@@ -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"
},
+2
View File
@@ -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<string, { enum?: string[]; default?: string }>;
expect(properties.target).toMatchObject({ enum: ["resume", "cover-letter"], default: "resume" });
});
it("advertises application tracker tools", () => {
+11 -3
View File
@@ -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]: {
+61 -1
View File
@@ -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");
});
+30 -21
View File
@@ -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<typeof rou
}),
);
// ── Download Resume PDF ──────────────────────────────────────
// ── Download Resume or Cover Letter PDF ───────────────────────
server.registerTool(
T.downloadResumePdf,
TOOL_META[T.downloadResumePdf],
withErrorHandling("creating PDF download URL", async ({ id }: { id: string }) => {
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 ─────────────────────────────────────────────
+3
View File
@@ -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