import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import type { RouterClient } from "@orpc/server"; import type router from "@reactive-resume/api/routers"; import z from "zod"; import { resumePatchOperationsSchema } from "@reactive-resume/ai/tools/resume-tool-contracts"; import { resolveUserFromRequestHeaders } from "@reactive-resume/api/context"; import { createResumePdfDownloadUrl, PDF_DOWNLOAD_URL_EXPIRES_IN_SECONDS, } from "@reactive-resume/api/features/resume/export"; import { env } from "@reactive-resume/env/server"; import { resumeDataSchema } from "@reactive-resume/schema/resume/data"; import { MCP_TOOL_NAME } from "./mcp-tool-names"; import { TOOL_ANNOTATIONS } from "./tool-annotations"; export { MCP_TOOL_NAME } from "./mcp-tool-names"; type PatchOperation = z.infer[number]; // ── Shared Helpers ────────────────────────────────────────────── function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } function errorHint(error: unknown): string { const msg = errorMessage(error); const { unlockResume, listResumes, getResume } = MCP_TOOL_NAME; if (msg.includes("slug already exists")) return "\n\nHint: The slug is already in use. Try a different one."; if (msg.includes("locked")) return `\n\nHint: This resume is locked. Use \`${unlockResume}\` first.`; if (msg.includes("404") || msg.includes("not found")) return `\n\nHint: Resume not found. Use \`${listResumes}\` to find valid IDs.`; if (msg.includes("400")) return `\n\nHint: Invalid request. Check the input parameters or use \`${getResume}\` to inspect the resume structure.`; if (msg.includes("403")) return `\n\nHint: Permission denied. The resume may be locked — use \`${unlockResume}\` first.`; return ""; } /** * Wraps an async tool handler with consistent error formatting. * On success, returns the handler's result directly. * On failure, returns `{ isError: true, content: [{ type: "text", text }] }` with actionable hints. */ function withErrorHandling(label: string, handler: (params: T) => Promise) { return async (params: T): Promise => { try { return await handler(params); } catch (error) { return { isError: true, content: [{ type: "text", text: `Error ${label}: ${errorMessage(error)}${errorHint(error)}` }], }; } }; } function text(value: string): CallToolResult { return { content: [{ type: "text", text: value }] }; } function buildResumeShareUrl(username: string, slug: string): string { const base = env.APP_URL.replace(/\/$/, ""); return `${base}/${encodeURIComponent(username)}/${encodeURIComponent(slug)}`; } function resumeShareUrlNotes(input: { isPublic: boolean; hasPassword: boolean }): string { const lines = [ "Anyone can open this link without signing in only when the resume is public (`isPublic: true`).", input.isPublic ? "This resume is currently public." : "This resume is currently private; the URL is still your canonical share link if you make it public later.", ]; if (input.hasPassword) lines.push( "Password protection is enabled in the web app; visitors may need that password before content is shown.", ); return lines.join("\n"); } // ── Shared Zod Fragments ──────────────────────────────────────── const T = MCP_TOOL_NAME; const resumeIdSchema = z.string().min(1).describe(`Resume ID. Use \`${T.listResumes}\` to find valid IDs.`); // ── Tool Registration ─────────────────────────────────────────── export function registerTools(server: McpServer, client: RouterClient, requestHeaders: Headers) { // ── List Resumes ────────────────────────────────────────────── server.registerTool( T.listResumes, { title: "List Resumes", description: [ "Primary way to discover resume IDs for this account. Resumes are not listed as MCP resources;", "use this tool (not `resources/list`) to enumerate IDs.", "", "Returns an array of resume objects (without full resume data) containing:", "id, name, slug, tags, isPublic, isLocked, createdAt, updatedAt.", "", `Call this before \`${T.getResume}\`, \`${T.patchResume}\`, prompts, or \`resources/read\` with \`resume://{id}\`.`, "Results can be filtered by tags and sorted by last updated date, creation date, or name.", ].join("\n"), inputSchema: z.object({ tags: z .array(z.string()) .optional() .default([]) .describe( "Filter resumes by tags. Only resumes matching ALL specified tags are returned. Default: no filter.", ), sort: z .enum(["lastUpdatedAt", "createdAt", "name"]) .optional() .default("lastUpdatedAt") .describe("Sort order for results. Default: lastUpdatedAt."), }), annotations: TOOL_ANNOTATIONS[T.listResumes], }, withErrorHandling( "listing resumes", async ({ tags, sort }: { tags: string[]; sort: "lastUpdatedAt" | "createdAt" | "name" }) => { const resumes = await client.resume.list({ tags, sort }); if (resumes.length === 0) return text(`No resumes found. Use \`${T.createResume}\` to create one.`); return text(JSON.stringify(resumes, null, 2)); }, ), ); // ── List Resume Tags ────────────────────────────────────────── server.registerTool( T.listResumeTags, { title: "List Resume Tags", description: [ "Returns a sorted list of every distinct tag used across your resumes.", "Useful for choosing tag filters when calling list tools or keeping naming consistent.", ].join("\n"), inputSchema: z.object({}), annotations: TOOL_ANNOTATIONS[T.listResumeTags], }, withErrorHandling("listing resume tags", async () => { const tags = await client.resume.tags.list(); if (tags.length === 0) return text("No tags in use yet. Add tags when creating or updating a resume."); return text(JSON.stringify(tags, null, 2)); }), ); // ── Read Resume ─────────────────────────────────────────────── server.registerTool( T.getResume, { title: "Read Resume", description: [ "Get the full data of a specific resume by its ID.", "", "Returns the complete resume data as JSON, including: basics (name, headline, email, phone,", "location, website), summary, picture settings, all sections (experience, education, skills,", "projects, etc.), custom sections, and metadata (template, layout, typography, colors).", "", `Use \`${T.listResumes}\` first to find valid IDs.`, "The `resume://_meta/schema` resource describes the full data structure for JSON Patch paths.", ].join("\n"), inputSchema: z.object({ id: resumeIdSchema }), annotations: TOOL_ANNOTATIONS[T.getResume], }, withErrorHandling("getting resume", async ({ id }: { id: string }) => { const resume = await client.resume.getById({ id }); return text(JSON.stringify(resume.data, null, 2)); }), ); // ── Get Resume Analysis ─────────────────────────────────────── server.registerTool( T.getResumeAnalysis, { title: "Get Resume Analysis", description: [ "Returns the latest saved AI analysis for a resume (scorecard, strengths, suggestions), if any.", "Analyses are created from the Reactive Resume web app AI flow, not from MCP.", `Returns JSON or a short message if none exists. Use \`${T.listResumes}\` to find resume IDs.`, ].join("\n"), inputSchema: z.object({ id: resumeIdSchema }), annotations: TOOL_ANNOTATIONS[T.getResumeAnalysis], }, withErrorHandling("getting resume analysis", async ({ id }: { id: string }) => { const analysis = await client.resume.analysis.getById({ id }); if (!analysis) return text("No saved analysis for this resume yet."); return text(JSON.stringify(analysis, null, 2)); }), ); // ── Download Resume PDF ─────────────────────────────────────── server.registerTool( T.downloadResumePdf, { title: "Download Resume PDF", description: [ "Create a short-lived authenticated URL for downloading a resume as a PDF.", `The URL expires in ${PDF_DOWNLOAD_URL_EXPIRES_IN_SECONDS / 60} minutes and should be used immediately.`, "Returns JSON containing: resumeId, name, downloadUrl, expiresAt, expiresInSeconds, contentType.", `Use \`${T.listResumes}\` first to find valid IDs.`, ].join("\n"), inputSchema: z.object({ id: resumeIdSchema }), annotations: TOOL_ANNOTATIONS[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"); const signedUrl = createResumePdfDownloadUrl({ resumeId: id, userId: user.id }); return text( JSON.stringify( { resumeId: id, name: resume.name, downloadUrl: signedUrl.url, expiresAt: signedUrl.expiresAt, expiresInSeconds: signedUrl.expiresInSeconds, contentType: "application/pdf", }, null, 2, ), ); }), ); // ── Create Resume ───────────────────────────────────────────── server.registerTool( T.createResume, { title: "Create Resume", description: [ "Create a new, empty resume with a name and URL-friendly slug.", "", "Returns the ID of the newly created resume.", "Set `withSampleData` to true to pre-fill with example content (useful for testing).", `After creating, use \`${T.getResume}\` to view or \`${T.patchResume}\` to populate it.`, ].join("\n"), inputSchema: z.object({ name: z.string().min(1).max(64).describe("Display name for the resume (e.g. 'Software Engineer 2026')"), slug: z .string() .min(1) .max(64) .describe("URL-friendly slug, must be unique across your resumes (e.g. 'software-engineer-2026')"), tags: z .array(z.string()) .optional() .default([]) .describe("Tags to categorize the resume (e.g. ['tech', 'senior'])"), withSampleData: z.boolean().optional().default(false).describe("Pre-fill with sample data. Default: false."), }), annotations: TOOL_ANNOTATIONS[T.createResume], }, withErrorHandling( "creating resume", async ({ name, slug, tags, withSampleData, }: { name: string; slug: string; tags: string[]; withSampleData: boolean; }) => { const id = await client.resume.create({ name, slug, tags, withSampleData }); return text( `Created resume "${name}" (ID: ${id}) with slug "${slug}".${withSampleData ? " Pre-filled with sample data." : ""}\n\nNext steps: Use \`${T.getResume}\` to view it, or \`${T.patchResume}\` to start editing.`, ); }, ), ); // ── Import Resume ───────────────────────────────────────────── server.registerTool( T.importResume, { title: "Import Resume", description: [ "Create a new resume from a full ResumeData JSON object (e.g. an exported file from Reactive Resume).", "A random name and slug are assigned automatically, like the web importer.", `For small edits to an existing resume, prefer \`${T.patchResume}\` instead of re-importing.`, "Large payloads may exceed MCP client message limits — in that case, use the web UI or the HTTP API.", ].join("\n"), inputSchema: z.object({ data: z .unknown() .describe("Complete ResumeData JSON (same shape as `read_resume` body or `resume://_meta/schema`)."), }), annotations: TOOL_ANNOTATIONS[T.importResume], }, withErrorHandling("importing resume", async ({ data }: { data: unknown }) => { const parsed = resumeDataSchema.safeParse(data); if (!parsed.success) return { isError: true, content: [ { type: "text", text: `Invalid ResumeData: ${parsed.error.message}\n\nHint: Ensure the JSON matches the schema at resume://_meta/schema`, }, ], }; const id = await client.resume.import({ data: parsed.data }); return text( `Imported resume (ID: ${id}).\n\nNext steps: Use \`${T.getResume}\` to inspect metadata (name/slug were auto-generated), or \`${T.updateResume}\` / \`${T.patchResume}\` to adjust.`, ); }), ); // ── Duplicate Resume ────────────────────────────────────────── server.registerTool( T.duplicateResume, { title: "Duplicate Resume", description: [ "Create a copy of an existing resume with all its data.", "", "Returns the ID of the newly duplicated resume.", "You must provide a new name and slug for the copy.", "Useful for creating job-specific variants of a base resume.", ].join("\n"), inputSchema: z.object({ id: resumeIdSchema.describe("ID of the resume to duplicate"), name: z.string().min(1).max(64).describe("Name for the duplicate"), slug: z.string().min(1).max(64).describe("URL-friendly slug for the duplicate (must be unique)"), tags: z.array(z.string()).optional().default([]).describe("Tags for the duplicate"), }), annotations: TOOL_ANNOTATIONS[T.duplicateResume], }, withErrorHandling( "duplicating resume", async ({ id, name, slug, tags }: { id: string; name: string; slug: string; tags: string[] }) => { const newId = await client.resume.duplicate({ id, name, slug, tags }); return text( `Duplicated resume as "${name}" (ID: ${newId}) with slug "${slug}".\n\nNext steps: Use \`${T.getResume}\` to view it, or \`${T.patchResume}\` to customize.`, ); }, ), ); // ── Apply Resume Patch ──────────────────────────────────────── server.registerTool( T.patchResume, { title: "Apply Resume Patch", description: [ "Apply JSON Patch (RFC 6902) operations to partially update a resume's data.", "", `This is the primary way to edit resume content. Use \`${T.getResume}\` first to inspect the`, "current structure, and `resume://_meta/schema` to understand valid paths and types.", "", "Supported operations: add, remove, replace, move, copy, test.", "", "Common path examples:", " /basics/name — Change the name", " /basics/headline — Change the headline", " /summary/content — Replace summary (HTML string)", " /sections/experience/items/- — Append a new experience item", " /sections/experience/items/0/company — Update first experience's company", " /sections/skills/items/- — Append a new skill", " /metadata/template — Change the template (e.g. 'azurill', 'bronzor', 'onyx')", " /metadata/design/colors/primary — Change the primary color (rgba string)", " /sections/interests/hidden — Hide/show a section", "", "Important: HTML content fields (description, summary.content) must use valid HTML.", "New items must include a valid UUID as `id` and `hidden: false`.", `Locked resumes cannot be patched — use \`${T.unlockResume}\` first.`, ].join("\n"), inputSchema: z.object({ id: resumeIdSchema, operations: resumePatchOperationsSchema, }), annotations: TOOL_ANNOTATIONS[T.patchResume], }, withErrorHandling("patching resume", async ({ id, operations }: { id: string; operations: PatchOperation[] }) => { const resume = await client.resume.patch({ id, operations }); const summary = operations.map((op) => `${op.op} ${op.path}`).join(", "); return text(`Applied ${operations.length} operation(s) to "${resume.name}": ${summary}`); }), ); // ── Update Resume (metadata) ───────────────────────────────── server.registerTool( T.updateResume, { title: "Update Resume (metadata)", description: [ "Update resume metadata only: display name, URL slug, tags, and/or public visibility.", "Does not change section content — use JSON Patch via the patch tool for body edits.", `Locked resumes cannot be updated; use \`${T.unlockResume}\` first.`, "Password protection cannot be set or removed via MCP; use the web app for that.", "", "Always returns your canonical share URL (`{app}/{username}/{slug}`). Anonymous viewers can use it only when `isPublic` is true; password protection from the web app still applies.", ].join("\n"), inputSchema: z.object({ id: resumeIdSchema, name: z.string().min(1).max(64).optional().describe("Display name for the resume."), slug: z.string().min(1).max(64).optional().describe("URL-friendly slug; must stay unique among your resumes."), tags: z.array(z.string()).optional().describe("Replace the resume's tags (omit to leave unchanged)."), isPublic: z .boolean() .optional() .describe( "When true, anyone with the link can view the public resume (subject to password if set in the app).", ), }), annotations: TOOL_ANNOTATIONS[T.updateResume], }, withErrorHandling("updating resume", async (params) => { const { id, name, slug, tags, isPublic } = params; if (name === undefined && slug === undefined && tags === undefined && isPublic === undefined) throw new Error("Provide at least one of: name, slug, tags, isPublic."); const resume = await client.resume.update({ id, ...(name !== undefined ? { name } : {}), ...(slug !== undefined ? { slug } : {}), ...(tags !== undefined ? { tags } : {}), ...(isPublic !== undefined ? { isPublic } : {}), }); const user = await resolveUserFromRequestHeaders(requestHeaders); const username = user && "username" in user && typeof (user as { username: unknown }).username === "string" ? (user as { username: string }).username : ""; const shareUrl = username !== "" ? buildResumeShareUrl(username, resume.slug) : "(could not build share URL — missing username on account)"; const payload = { id: resume.id, name: resume.name, slug: resume.slug, tags: resume.tags, isPublic: resume.isPublic, hasPassword: resume.hasPassword, shareUrl, }; return text( [ JSON.stringify(payload, null, 2), "", resumeShareUrlNotes({ isPublic: resume.isPublic, hasPassword: resume.hasPassword }), ].join("\n"), ); }), ); // ── Delete Resume ───────────────────────────────────────────── server.registerTool( T.deleteResume, { title: "Delete Resume", description: [ "Permanently delete a resume and all its associated files (screenshots, PDFs).", "", `This action is IRREVERSIBLE. Locked resumes cannot be deleted — use \`${T.unlockResume}\` first.`, `Consider using \`${T.duplicateResume}\` to create a backup before deleting.`, ].join("\n"), inputSchema: z.object({ id: resumeIdSchema }), annotations: TOOL_ANNOTATIONS[T.deleteResume], }, withErrorHandling("deleting resume", async ({ id }: { id: string }) => { await client.resume.delete({ id }); return text(`Successfully deleted resume (${id}) and all associated files.`); }), ); // ── Lock Resume ─────────────────────────────────────────────── server.registerTool( T.lockResume, { title: "Lock Resume", description: [ "Lock a resume to prevent any modifications.", "", `When locked, a resume cannot be edited (${T.patchResume}, ${T.updateResume}), or deleted.`, "Useful for protecting finalized resumes from accidental changes.", `Use \`${T.unlockResume}\` to re-enable editing.`, ].join("\n"), inputSchema: z.object({ id: resumeIdSchema }), annotations: TOOL_ANNOTATIONS[T.lockResume], }, withErrorHandling("locking resume", async ({ id }: { id: string }) => { await client.resume.setLocked({ id, isLocked: true }); return text(`Resume (${id}) is now locked. It cannot be edited, patched, or deleted until unlocked.`); }), ); // ── Unlock Resume ───────────────────────────────────────────── server.registerTool( T.unlockResume, { title: "Unlock Resume", description: "Unlock a previously locked resume, re-enabling edits, patches, and deletion.", inputSchema: z.object({ id: resumeIdSchema }), annotations: TOOL_ANNOTATIONS[T.unlockResume], }, withErrorHandling("unlocking resume", async ({ id }: { id: string }) => { await client.resume.setLocked({ id, isLocked: false }); return text(`Resume (${id}) is now unlocked. It can be edited, patched, and deleted.`); }), ); // ── Get Resume Statistics ──────────────────────────────────── server.registerTool( T.getResumeStatistics, { title: "Get Resume Statistics", description: [ "Get view and download statistics for a resume.", "", "Returns: isPublic (boolean), views (count), downloads (count),", "lastViewedAt (timestamp or null), lastDownloadedAt (timestamp or null).", ].join("\n"), inputSchema: z.object({ id: resumeIdSchema }), annotations: TOOL_ANNOTATIONS[T.getResumeStatistics], }, withErrorHandling("getting resume statistics", async ({ id }: { id: string }) => { const stats = await client.resume.statistics.getById({ id }); return text(JSON.stringify(stats, null, 2)); }), ); }