mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-24 23:32:19 +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
@@ -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 ─────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user