feat: implement download_resume_pdf mcp tool

This commit is contained in:
Amruth Pillai
2026-06-01 10:26:28 +02:00
parent 9ce5bacd22
commit 0df7f21130
35 changed files with 1951 additions and 1602 deletions
+20 -20
View File
@@ -17,24 +17,24 @@
"#react-pdf-renderer": "@react-pdf/renderer"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.80",
"@ai-sdk/anthropic": "^3.0.81",
"@ai-sdk/google": "^3.0.80",
"@ai-sdk/openai": "^3.0.65",
"@ai-sdk/openai": "^3.0.67",
"@ai-sdk/openai-compatible": "^2.0.48",
"@aws-sdk/client-s3": "^3.1055.0",
"@better-auth/api-key": "^1.6.11",
"@better-auth/drizzle-adapter": "^1.6.11",
"@better-auth/infra": "^0.2.10",
"@better-auth/oauth-provider": "^1.6.11",
"@better-auth/passkey": "^1.6.11",
"@aws-sdk/client-s3": "^3.1057.0",
"@better-auth/api-key": "^1.6.13",
"@better-auth/drizzle-adapter": "^1.6.13",
"@better-auth/infra": "^0.2.11",
"@better-auth/oauth-provider": "^1.6.13",
"@better-auth/passkey": "^1.6.13",
"@hono/node-server": "^2.0.4",
"@modelcontextprotocol/sdk": "^1.29.0",
"@orpc/client": "^1.14.3",
"@orpc/experimental-ratelimit": "^1.14.3",
"@orpc/json-schema": "^1.14.3",
"@orpc/openapi": "^1.14.3",
"@orpc/server": "^1.14.3",
"@orpc/zod": "^1.14.3",
"@orpc/client": "^1.14.4",
"@orpc/experimental-ratelimit": "^1.14.4",
"@orpc/json-schema": "^1.14.4",
"@orpc/openapi": "^1.14.4",
"@orpc/server": "^1.14.4",
"@orpc/zod": "^1.14.4",
"@react-pdf/renderer": "^4.5.1",
"@reactive-resume/api": "workspace:*",
"@reactive-resume/auth": "workspace:*",
@@ -46,9 +46,9 @@
"@sindresorhus/slugify": "^3.0.0",
"@t3-oss/env-core": "^0.13.11",
"@uiw/color-convert": "^2.10.3",
"ai": "^6.0.191",
"ai": "^6.0.193",
"bcrypt": "^6.0.0",
"better-auth": "1.6.11",
"better-auth": "1.6.13",
"cjk-regex": "^3.4.0",
"deepmerge-ts": "^7.1.5",
"dompurify": "^3.4.7",
@@ -60,7 +60,7 @@
"hono": "^4.12.23",
"jsonrepair": "^3.14.0",
"node-html-parser": "^7.1.0",
"nodemailer": "^8.0.9",
"nodemailer": "^8.0.10",
"ollama-ai-provider-v2": "^3.5.1",
"pg": "^8.21.0",
"phosphor-icons-react-pdf": "^0.1.3",
@@ -79,9 +79,9 @@
"@types/node": "^25.9.1",
"@types/pg": "^8.20.0",
"@types/react": "^19.2.15",
"@typescript/native-preview": "7.0.0-dev.20260527.1",
"tsdown": "^0.22.0",
"tsx": "^4.22.3",
"@typescript/native-preview": "7.0.0-dev.20260527.2",
"tsdown": "^0.22.1",
"tsx": "^4.22.4",
"typescript": "^6.0.3",
"vitest": "^4.1.7"
}
+19
View File
@@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
handleHealth: vi.fn(),
handleUpload: vi.fn(),
handleMcp: vi.fn(),
handleResumePdfDownload: vi.fn(),
handleMcpServerCard: vi.fn(),
handleOAuthAuthorizationServer: vi.fn(),
handleOAuthProtectedResource: vi.fn(),
@@ -66,6 +67,10 @@ vi.mock("../mcp/handler", () => ({
handleMcp: mocks.handleMcp,
}));
vi.mock("./resume-pdf", () => ({
handleResumePdfDownload: mocks.handleResumePdfDownload,
}));
beforeEach(() => {
vi.clearAllMocks();
mocks.handleAuth.mockResolvedValue(new Response("auth"));
@@ -75,6 +80,7 @@ beforeEach(() => {
mocks.handleHealth.mockReturnValue(new Response("health"));
mocks.handleUpload.mockResolvedValue(new Response("upload"));
mocks.handleMcp.mockResolvedValue(new Response("mcp"));
mocks.handleResumePdfDownload.mockResolvedValue(new Response("pdf"));
mocks.handleMcpServerCard.mockReturnValue(new Response("server-card"));
mocks.handleOAuthAuthorizationServer.mockReturnValue(new Response("oauth-authorization-server"));
mocks.handleOAuthProtectedResource.mockReturnValue(new Response("oauth-protected-resource"));
@@ -101,6 +107,19 @@ describe("createApp", () => {
expect(mocks.handleAuth).not.toHaveBeenCalled();
});
it("routes signed resume PDF downloads before the web fallback", async () => {
const { createApp } = await import("./app");
const app = createApp();
const request = new Request("http://localhost:3001/api/resumes/resume-1/pdf?token=signed");
const response = await app.fetch(request);
await expect(response.text()).resolves.toBe("pdf");
expect(mocks.handleResumePdfDownload).toHaveBeenCalledWith(request, "resume-1");
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
expect(mocks.handleWebApp).not.toHaveBeenCalled();
});
it.each([
["GET", "/robots.txt", "robots", mocks.handleRobots],
["HEAD", "/robots.txt", "", mocks.handleRobots],
+2
View File
@@ -15,6 +15,7 @@ import { handleUpload } from "../static/uploads";
import { handleWebApp, handleWebAppHead, serveWebDistStatic } from "../static/web";
import { handleAuth, handleOAuth } from "./auth";
import { handleHealth } from "./health";
import { handleResumePdfDownload } from "./resume-pdf";
export function createApp() {
const app = new Hono();
@@ -26,6 +27,7 @@ export function createApp() {
app.get("/api/auth/oauth", (c) => handleOAuth(c.req.raw));
app.all("/api/auth/*", (c) => handleAuth(c.req.raw));
app.get("/api/health", () => handleHealth());
app.get("/api/resumes/:id/pdf", (c) => handleResumePdfDownload(c.req.raw, c.req.param("id")));
app.get("/api/uploads/*", (c) => handleUpload(c.req.raw));
app.get("/uploads/*", (c) => handleUpload(c.req.raw));
app.get("/schema.json", () => handleSchemaJson());
+70
View File
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
createResumePdfDownload: vi.fn(),
verifyResumePdfDownloadToken: vi.fn(),
}));
vi.mock("@reactive-resume/api/features/resume/export", () => ({
createResumePdfDownload: mocks.createResumePdfDownload,
verifyResumePdfDownloadToken: mocks.verifyResumePdfDownloadToken,
}));
const { handleResumePdfDownload } = await import("./resume-pdf");
describe("handleResumePdfDownload", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders the PDF when the signed URL token is valid", async () => {
const pdf = new File([new Uint8Array([37, 80, 68, 70])], "Scizor.pdf", { type: "application/pdf" });
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="Scizor.pdf"' },
body: pdf,
});
const response = await handleResumePdfDownload(
new Request("https://example.com/api/resumes/resume-1/pdf?token=signed"),
"resume-1",
);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("application/pdf");
expect(response.headers.get("Content-Disposition")).toBe('attachment; filename="Scizor.pdf"');
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
expect(await response.text()).toBe("%PDF");
expect(mocks.createResumePdfDownload).toHaveBeenCalledWith({ id: "resume-1", userId: "user-1" });
});
it("rejects missing, invalid, and expired tokens before rendering", async () => {
let response = await handleResumePdfDownload(
new Request("https://example.com/api/resumes/resume-1/pdf"),
"resume-1",
);
expect(response.status).toBe(401);
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({ ok: false, reason: "invalid_signature" });
response = await handleResumePdfDownload(
new Request("https://example.com/api/resumes/resume-1/pdf?token=bad"),
"resume-1",
);
expect(response.status).toBe(401);
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({ ok: false, reason: "expired" });
response = await handleResumePdfDownload(
new Request("https://example.com/api/resumes/resume-1/pdf?token=expired"),
"resume-1",
);
expect(response.status).toBe(410);
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
});
});
+53
View File
@@ -0,0 +1,53 @@
import { createResumePdfDownload, verifyResumePdfDownloadToken } from "@reactive-resume/api/features/resume/export";
function unauthorizedResponse() {
return new Response("Unauthorized", {
status: 401,
headers: {
"Cache-Control": "private, no-store",
},
});
}
function expiredResponse() {
return new Response("Download link expired", {
status: 410,
headers: {
"Cache-Control": "private, no-store",
},
});
}
function errorStatus(error: unknown) {
const code = typeof error === "object" && error && "code" in error ? (error as { code?: unknown }).code : undefined;
return code === "NOT_FOUND" ? 404 : 500;
}
export async function handleResumePdfDownload(request: Request, id: string) {
const token = new URL(request.url).searchParams.get("token");
if (!token) return unauthorizedResponse();
const verification = verifyResumePdfDownloadToken({ resumeId: id, token });
if (!verification.ok) return verification.reason === "expired" ? expiredResponse() : unauthorizedResponse();
try {
const download = await createResumePdfDownload({ id, userId: verification.userId });
return new Response(download.body, {
headers: {
"Content-Type": download.body.type || "application/pdf",
"Content-Disposition": download.headers["content-disposition"],
"Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
},
});
} catch (error) {
console.error("[PDF Download]", error);
return new Response("Failed to generate resume PDF", {
status: errorStatus(error),
headers: {
"Cache-Control": "private, no-store",
},
});
}
}
+1
View File
@@ -52,6 +52,7 @@ export async 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}\`.`,
`Import full ResumeData JSON with \`${MCP_TOOL_NAME.importResume}\`; read saved AI analysis with \`${MCP_TOOL_NAME.getResumeAnalysis}\`.`,
].join(" "),
},
+27 -27
View File
@@ -16,20 +16,20 @@
"lingui:extract": "lingui extract --clean --overwrite"
},
"dependencies": {
"@ai-sdk/react": "^3.0.193",
"@ai-sdk/react": "^3.0.195",
"@base-ui/react": "^1.5.0",
"@better-auth/api-key": "^1.6.11",
"@better-auth/infra": "^0.2.10",
"@better-auth/oauth-provider": "^1.6.11",
"@better-auth/passkey": "^1.6.11",
"@better-auth/api-key": "^1.6.13",
"@better-auth/infra": "^0.2.11",
"@better-auth/oauth-provider": "^1.6.13",
"@better-auth/passkey": "^1.6.13",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@lingui/core": "^6.1.0",
"@lingui/react": "^6.1.0",
"@orpc/client": "^1.14.3",
"@orpc/server": "^1.14.3",
"@orpc/tanstack-query": "^1.14.3",
"@orpc/client": "^1.14.4",
"@orpc/server": "^1.14.4",
"@orpc/tanstack-query": "^1.14.4",
"@phosphor-icons/react": "^2.1.10",
"@react-pdf/renderer": "^4.5.1",
"@reactive-resume/ai": "workspace:*",
@@ -43,31 +43,31 @@
"@reactive-resume/ui": "workspace:*",
"@reactive-resume/utils": "workspace:*",
"@tailwindcss/vite": "^4.3.0",
"@tanstack/react-form": "^1.32.1",
"@tanstack/react-form": "^1.33.0",
"@tanstack/react-hotkeys": "^0.10.0",
"@tanstack/react-query": "^5.100.14",
"@tanstack/react-router": "^1.170.8",
"@tiptap/extension-color": "^3.23.6",
"@tiptap/extension-highlight": "^3.23.6",
"@tiptap/extension-table": "^3.23.6",
"@tiptap/extension-text-align": "^3.23.6",
"@tiptap/extension-text-style": "^3.23.6",
"@tiptap/pm": "^3.23.6",
"@tiptap/react": "^3.23.6",
"@tiptap/starter-kit": "^3.23.6",
"@tanstack/react-router": "^1.170.10",
"@tiptap/extension-color": "^3.24.0",
"@tiptap/extension-highlight": "^3.24.0",
"@tiptap/extension-table": "^3.24.0",
"@tiptap/extension-text-align": "^3.24.0",
"@tiptap/extension-text-style": "^3.24.0",
"@tiptap/pm": "^3.24.0",
"@tiptap/react": "^3.24.0",
"@tiptap/starter-kit": "^3.24.0",
"@types/js-cookie": "^3.0.6",
"@uiw/color-convert": "^2.10.3",
"@uiw/react-color-colorful": "^2.10.3",
"ai": "^6.0.191",
"better-auth": "1.6.11",
"ai": "^6.0.193",
"better-auth": "1.6.13",
"cmdk": "^1.1.1",
"drizzle-orm": "1.0.0-rc.3",
"es-toolkit": "^1.47.0",
"fuse.js": "^7.3.0",
"fuse.js": "^7.4.0",
"immer": "^11.1.8",
"js-cookie": "^3.0.7",
"js-cookie": "^3.0.8",
"motion": "^12.40.0",
"pdfjs-dist": "5.7.284",
"pdfjs-dist": "6.0.227",
"pg": "^8.21.0",
"qrcode.react": "^4.2.0",
"react": "^19.2.6",
@@ -80,7 +80,7 @@
"ts-pattern": "^5.9.0",
"usehooks-ts": "^3.1.1",
"zod": "^4.4.3",
"zustand": "^5.0.13"
"zustand": "^5.0.14"
},
"devDependencies": {
"@babel/core": "^7.29.7",
@@ -90,16 +90,16 @@
"@lingui/vite-plugin": "^6.1.0",
"@reactive-resume/config": "workspace:*",
"@rolldown/plugin-babel": "^0.2.3",
"@tanstack/router-plugin": "^1.168.11",
"@tanstack/router-plugin": "^1.168.13",
"@types/babel__core": "^7.20.5",
"@types/pg": "^8.20.0",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@typescript/native-preview": "7.0.0-dev.20260527.1",
"@typescript/native-preview": "7.0.0-dev.20260527.2",
"@vitejs/plugin-react": "^6.0.2",
"babel-plugin-macros": "^3.1.0",
"babel-plugin-react-compiler": "^1.0.0",
"typescript": "^6.0.3",
"vite": "^8.0.14"
"vite": "^8.0.15"
}
}