mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-15 02:53:25 +10:00
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:
co-authored by
Amruth Pillai
parent
45303fb465
commit
6d9ebccc63
@@ -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"),
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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(" "),
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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]: {
|
||||
|
||||
@@ -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
@@ -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 ─────────────────────────────────────────────
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user