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
+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 ─────────────────────────────────────────────