diff --git a/docs/guides/using-the-mcp-server.mdx b/docs/guides/using-the-mcp-server.mdx
index 86317221d..bd2fc6525 100644
--- a/docs/guides/using-the-mcp-server.mdx
+++ b/docs/guides/using-the-mcp-server.mdx
@@ -13,95 +13,113 @@ The [Model Context Protocol (MCP)](https://modelcontextprotocol.io) is a standar
- Follow the [Using the API](/guides/using-the-api) guide to create an API key in your Reactive Resume dashboard.
-
+ Head over to [https://rxresu.me](https://rxresu.me) (or your self-hosted instance), sign in, and navigate to **Settings → API Keys**. Click **Create a new API key**, give it a name, and copy the secret — it's only shown once.
-
- The MCP server requires [Node.js](https://nodejs.org) version 18 or later.
-
-
-
- From the Reactive Resume repository root, install dependencies (the MCP server uses the main project's dependencies):
-
- ```bash
- cd /path/to/reactive-resume
- pnpm install
- ```
+ For the full walkthrough, see [Using the API](/guides/using-the-api).
-## Running the MCP server
-
-The server is a single TypeScript entry point and is run with **tsx** (no build step). From the repository root:
-
-```bash
-npx tsx /path/to/reactive-resume/mcp/index.ts
-```
-
-The working directory must be the **repository root** so that `node_modules` (and thus `@modelcontextprotocol/sdk`) is resolved. Configure your MCP client with `cwd` set to the repo path.
-
## Configuration
-### Claude Desktop
+There are two ways to connect, depending on whether your MCP client supports the Streamable HTTP transport natively.
-Add the following to your `claude_desktop_config.json`:
+### Method 1: Streamable HTTP (recommended)
+
+If your client supports the `url` field (e.g. **Cursor**), use this — no extra dependencies required:
+
+```json
+{
+ "mcpServers": {
+ "reactive-resume": {
+ "url": "https://rxresu.me/mcp",
+ "headers": {
+ "x-api-key": "your-api-key"
+ }
+ }
+ }
+}
+```
+
+### Method 2: mcp-remote
+
+If your client only supports `command` / `args` (e.g. **Claude Desktop**), use [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) as a bridge. This requires [Node.js](https://nodejs.org) **20 or later**.
```json
{
"mcpServers": {
"reactive-resume": {
"command": "npx",
- "args": ["tsx", "/path/to/reactive-resume/mcp/index.ts"],
- "cwd": "/path/to/reactive-resume",
- "env": {
- "REACTIVE_RESUME_API_KEY": "your-api-key",
- "REACTIVE_RESUME_URL": "https://rxresu.me"
- }
+ "args": [
+ "mcp-remote",
+ "https://rxresu.me/mcp",
+ "--header",
+ "x-api-key:your-api-key"
+ ]
}
}
}
```
- Replace `/path/to/reactive-resume` with the actual path to your cloned repository, and `your-api-key` with the API key you created.
+ Replace `your-api-key` with the API key you created in the prerequisites step.
-### Cursor
+### Where to put the config
-Add the following to `.cursor/mcp.json` in your project or home directory:
+| Client | Config file |
+| --- | --- |
+| Cursor | `.cursor/mcp.json` in your project or home directory |
+| Claude Desktop | `claude_desktop_config.json` ([docs](https://modelcontextprotocol.io/docs/tools/claude-desktop)) |
+| Other MCP clients | Refer to the client's documentation |
+
+## Self-Hosting
+
+If you're running a self-hosted Reactive Resume instance, replace `https://rxresu.me/mcp` with your instance URL:
```json
{
- "mcpServers": {
- "reactive-resume": {
- "command": "npx",
- "args": ["tsx", "/path/to/reactive-resume/mcp/index.ts"],
- "cwd": "/path/to/reactive-resume",
- "env": {
- "REACTIVE_RESUME_API_KEY": "your-api-key",
- "REACTIVE_RESUME_URL": "https://rxresu.me"
- }
- }
+ "url": "https://resume.example.com/mcp",
+ "headers": {
+ "x-api-key": "your-api-key"
}
}
```
## Available Tools
-The MCP server exposes three tools:
+The MCP server exposes the following tools:
| Tool | Description |
| --- | --- |
-| `list_resumes` | List all your resumes with their IDs, names, tags, and status |
+| `list_resumes` | List all resumes with IDs, names, tags, and status. Supports filtering by tags and sorting by last updated, creation date, or name |
| `get_resume` | Get the full data of a specific resume by ID |
-| `patch_resume` | Apply JSON Patch operations to modify a resume |
+| `create_resume` | Create a new, empty resume with a name and slug. Optionally pre-fill with sample data |
+| `duplicate_resume` | Create a copy of an existing resume with a new name and slug |
+| `patch_resume` | Apply JSON Patch (RFC 6902) operations to modify a resume's data |
+| `delete_resume` | Permanently delete a resume and all associated files. **Irreversible** |
+| `lock_resume` | Lock a resume to prevent edits, patches, and deletion |
+| `unlock_resume` | Unlock a previously locked resume to re-enable editing |
+| `export_resume_pdf` | Generate a PDF from the resume and return a download URL |
+| `get_resume_screenshot` | Get a visual preview of the resume's first page as a WebP image URL |
+| `get_resume_statistics` | Get view and download statistics for a resume |
## Available Resources
| Resource | Description |
| --- | --- |
-| `resume://{id}` | The full resume data as a readable JSON resource |
-| `resume://schema` | The ResumeData JSON schema for understanding the data structure |
+| `resume://{id}` | The full resume data as a readable JSON resource. Lists all resumes and supports reading individual ones by ID |
+| `resume://schema` | The ResumeData JSON Schema — reference this to understand valid paths and value types for JSON Patch operations |
+
+## Available Prompts
+
+Prompts are pre-built workflows that provide the AI with structured instructions and context. Each prompt embeds the resume data and schema automatically.
+
+| Prompt | Description |
+| --- | --- |
+| `build_resume` | Guide you step-by-step through building a resume from scratch — basics, summary, experience, education, skills, and design |
+| `improve_resume` | Review your resume and suggest concrete improvements to wording, impact, metrics, and structure |
+| `tailor_resume` | Adapt your resume to match a specific job description with keyword optimization and ATS targeting. Requires the job description as input |
+| `review_resume` | Get a structured, professional critique with a scorecard (1–10 across seven dimensions) and prioritized recommendations. **Read-only** — no changes are made |
## Usage Examples
@@ -112,6 +130,14 @@ Once your MCP client is connected, you can use natural language to interact with
- "List my resumes"
- "Show me my resume named 'Software Engineer'"
- "What skills are listed on my resume?"
+- "Show me the stats for my resume"
+
+### Creating & Managing
+
+- "Create a new resume called 'Frontend Engineer 2026'"
+- "Duplicate my 'Software Engineer' resume for a product manager role"
+- "Lock my finalized resume so it can't be accidentally edited"
+- "Delete my old draft resume"
### Editing
@@ -127,39 +153,28 @@ Once your MCP client is connected, you can use natural language to interact with
- "Set the primary color to blue"
- "Hide the interests section"
+### Exporting
+
+- "Export my resume as a PDF"
+- "Show me a screenshot of my resume"
+
+### Using Prompts
+
+- "Help me build my resume from scratch" (uses `build_resume`)
+- "Review my resume and give me a score" (uses `review_resume`)
+- "Improve the wording on my resume" (uses `improve_resume`)
+- "Tailor my resume for this job description: ..." (uses `tailor_resume`)
+
The AI will use `get_resume` to inspect your current resume before making changes with `patch_resume`. This ensures the correct JSON paths are used.
-## Self-Hosting
-
-If you're running a self-hosted Reactive Resume instance, set `REACTIVE_RESUME_URL` to your instance URL:
-
-```json
-{
- "env": {
- "REACTIVE_RESUME_API_KEY": "your-api-key",
- "REACTIVE_RESUME_URL": "https://resume.example.com"
- }
-}
-```
-
-## Environment Variables
-
-| Variable | Required | Default | Description |
-| --- | --- | --- | --- |
-| `REACTIVE_RESUME_API_KEY` | Yes | — | API key from Reactive Resume settings |
-| `REACTIVE_RESUME_URL` | No | `https://rxresu.me` | Base URL of the Reactive Resume instance |
-| `TRANSPORT` | No | `stdio` | Transport mode: `stdio` or `http` |
-| `PORT` | No | `3100` | Port for streamable HTTP transport |
-
## Troubleshooting
| Issue | Solution |
| --- | --- |
-| "REACTIVE_RESUME_API_KEY is required" | Set the `REACTIVE_RESUME_API_KEY` environment variable in your MCP client configuration |
-| "API error (401)" | Your API key is invalid or expired. Create a new one in the dashboard |
+| "API error (401)" | Your API key is invalid or expired. Create a new one in **Settings → API Keys** |
| "API error (404)" | The resume ID doesn't exist. Use `list_resumes` to find valid IDs |
| "API error (403)" | The resume is locked. Unlock it in the Reactive Resume dashboard |
-| Connection refused | Check that `REACTIVE_RESUME_URL` is correct and the instance is running |
-| Module not found / Cannot find package | Ensure `cwd` is the repository root so the main project's `node_modules` is used |
+| Connection refused | Check that the URL is correct and the instance is running |
+| "ReferenceError: File is not defined" when using `mcp-remote` | You're running Node.js 18. `mcp-remote` requires **Node.js 20 or later** — upgrade with `nvm use 20` or `nvm alias default 20` |
diff --git a/mcp/index.ts b/mcp/index.ts
deleted file mode 100644
index 5907fa5e6..000000000
--- a/mcp/index.ts
+++ /dev/null
@@ -1,434 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * Reactive Resume MCP server — single entry point. Run with:
- * npx tsx /path/to/reactive-resume/mcp
- *
- * Env: REACTIVE_RESUME_API_KEY (required), REACTIVE_RESUME_URL (default https://rxresu.me),
- * TRANSPORT (stdio | http), PORT (default 3100).
- */
-
-import * as http from "node:http";
-import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
-import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
-import type { Operation } from "fast-json-patch";
-import { z } from "zod";
-import type { RouterInput, RouterOutput } from "@/integrations/orpc/client";
-import { jsonPatchOperationSchema } from "@/utils/resume/patch";
-import schemaJSON from "../src/schema/schema.json";
-
-// --- Env ---
-const API_KEY = process.env.REACTIVE_RESUME_API_KEY;
-const BASE_URL = (process.env.REACTIVE_RESUME_URL ?? "https://rxresu.me").replace(/\/$/, "");
-const TRANSPORT = process.env.TRANSPORT ?? "stdio";
-const PORT = Number.parseInt(process.env.PORT ?? "3100", 10);
-
-if (!API_KEY) {
- console.error("ERROR: REACTIVE_RESUME_API_KEY is required. Create one in Settings > API Keys.");
- process.exit(1);
-}
-
-const apiKey = API_KEY;
-
-// --- API client ---
-async function apiRequest(method: string, path: string, body?: unknown): Promise {
- const url = `${BASE_URL}${path}`;
-
- const res = await fetch(url, {
- method,
- headers: {
- "x-api-key": apiKey,
- Accept: "application/json",
- "Content-Type": "application/json",
- },
- body: body ? JSON.stringify(body) : undefined,
- });
-
- if (!res.ok) {
- const text = await res.text().catch(() => "Unknown error");
- throw new Error(`Reactive Resume API error (${res.status}): ${text}`);
- }
-
- return res.json() as Promise;
-}
-
-async function listResumes(): Promise {
- return apiRequest("GET", "/api/openapi/resumes");
-}
-
-async function getResume(id: string): Promise {
- return apiRequest("GET", `/api/openapi/resumes/${encodeURIComponent(id)}`);
-}
-
-async function patchResume(id: string, operations: Operation[]): Promise {
- return apiRequest("PATCH", `/api/openapi/resumes/${encodeURIComponent(id)}`, { operations });
-}
-
-async function createResume(input: RouterInput["resume"]["create"]): Promise {
- return apiRequest("POST", "/api/openapi/resumes", input);
-}
-
-async function deleteResume(id: string): Promise {
- return apiRequest("DELETE", `/api/openapi/resumes/${encodeURIComponent(id)}`);
-}
-
-async function setResumeLocked(id: string, isLocked: boolean): Promise {
- return apiRequest("POST", `/api/openapi/resumes/${encodeURIComponent(id)}/lock`, { isLocked });
-}
-
-// --- MCP server ---
-const server = new McpServer({ name: "reactive-resume-mcp-server", version: "1.0.0" });
-
-// Tool: list_resumes
-server.registerTool(
- "list_resumes",
- {
- title: "List Resumes",
- description:
- "List all resumes for the authenticated user. Returns id, name, slug, tags, visibility, lock status, timestamps.",
- inputSchema: z.object({}).strict(),
- },
- async () => {
- try {
- const resumes = await listResumes();
- if (resumes.length === 0)
- return {
- content: [{ type: "text", text: "No resumes found. Use create_resume to create one." }],
- };
- const lines = [`# Your Resumes (${resumes.length})`, ""];
- for (const r of resumes) {
- const tags = r.tags.length > 0 ? ` [${r.tags.join(", ")}]` : "";
- const flags = [r.isPublic ? "public" : "private", r.isLocked ? "locked" : ""].filter(Boolean).join(", ");
- lines.push(`- **${r.name}** (${r.id})${tags}`);
- lines.push(` Status: ${flags} | Updated: ${r.updatedAt}`);
- }
- return { content: [{ type: "text", text: lines.join("\n") }] };
- } catch (error) {
- return {
- isError: true,
- content: [
- { type: "text", text: `Error listing resumes: ${error instanceof Error ? error.message : String(error)}` },
- ],
- };
- }
- },
-);
-
-// Tool: get_resume
-server.registerTool(
- "get_resume",
- {
- title: "Get Resume",
- description: "Get the full data of a specific resume by ID. Use list_resumes to find IDs.",
- inputSchema: z.object({ id: z.string().min(1).describe("Resume ID") }).strict(),
- },
- async ({ id }: { id: string }) => {
- try {
- const resume = await getResume(id);
- return { content: [{ type: "text", text: JSON.stringify(resume.data, null, 2) }] };
- } catch (error) {
- return {
- isError: true,
- content: [
- {
- type: "text",
- text: `Error getting resume: ${error instanceof Error ? error.message : String(error)}. Use list_resumes to find valid IDs.`,
- },
- ],
- };
- }
- },
-);
-
-// Tool: patch_resume
-server.registerTool(
- "patch_resume",
- {
- title: "Patch Resume",
- description:
- "Apply JSON Patch (RFC 6902) operations. Use get_resume first to inspect structure. Paths like /basics/name, /sections/skills/items/-.",
- inputSchema: z
- .object({
- id: z.string().min(1).describe("Resume ID"),
- operations: z.array(jsonPatchOperationSchema).min(1).describe("JSON Patch operations"),
- })
- .strict(),
- },
- async ({ id, operations }: { id: string; operations: Operation[] }) => {
- try {
- const resume = await patchResume(id, operations);
- const summary = operations.map((op) => `${op.op} ${op.path}`).join(", ");
-
- return {
- content: [{ type: "text", text: `Applied ${operations.length} operation(s) to "${resume.name}": ${summary}` }],
- };
- } catch (error) {
- const msg = error instanceof Error ? error.message : String(error);
- let hint = "";
- if (msg.includes("400")) hint = " Use get_resume to check structure and paths.";
- if (msg.includes("403")) hint = " Resume is locked. Use unlock_resume first.";
- if (msg.includes("404")) hint = " Use list_resumes to find valid IDs.";
- return { isError: true, content: [{ type: "text", text: `Error patching resume: ${msg}${hint}` }] };
- }
- },
-);
-
-// Tool: create_resume
-server.registerTool(
- "create_resume",
- {
- title: "Create Resume",
- description:
- "Create a new resume with a name, slug, and optional tags. Set withSampleData to true to pre-fill with example content.",
- inputSchema: z
- .object({
- name: z.string().min(1).max(64).describe("Name for the new resume"),
- slug: z.string().min(1).max(64).describe("URL-friendly slug (must be unique across your resumes)"),
- tags: z.array(z.string()).optional().default([]).describe("Optional tags to categorize the resume"),
- withSampleData: z.boolean().optional().default(false).describe("If true, pre-fill the resume with sample data"),
- })
- .strict(),
- },
- async ({
- name,
- slug,
- tags,
- withSampleData,
- }: {
- name: string;
- slug: string;
- tags: string[];
- withSampleData: boolean;
- }) => {
- try {
- const id = await createResume({ name, slug, tags, withSampleData });
-
- return {
- content: [
- {
- type: "text",
- text: `Created resume "${name}" (${id}) with slug "${slug}".${withSampleData ? " Pre-filled with sample data." : ""} Use get_resume to view or patch_resume to edit it.`,
- },
- ],
- };
- } catch (error) {
- const msg = error instanceof Error ? error.message : String(error);
- let hint = "";
- if (msg.includes("400")) hint = " The slug may already be in use — try a different one.";
- return { isError: true, content: [{ type: "text", text: `Error creating resume: ${msg}${hint}` }] };
- }
- },
-);
-
-// Tool: delete_resume
-server.registerTool(
- "delete_resume",
- {
- title: "Delete Resume",
- description:
- "Permanently delete a resume and its associated files (screenshots, PDFs). Locked resumes cannot be deleted — unlock first. This action is irreversible.",
- inputSchema: z
- .object({
- id: z.string().min(1).describe("Resume ID to delete. Use list_resumes to find IDs."),
- })
- .strict(),
- },
- async ({ id }: { id: string }) => {
- try {
- await deleteResume(id);
-
- return { content: [{ type: "text", text: `Successfully deleted resume (${id}) and its associated files.` }] };
- } catch (error) {
- const msg = error instanceof Error ? error.message : String(error);
- let hint = "";
- if (msg.includes("403")) hint = " The resume may be locked. Use unlock_resume first.";
- if (msg.includes("404")) hint = " Use list_resumes to find valid IDs.";
- return { isError: true, content: [{ type: "text", text: `Error deleting resume: ${msg}${hint}` }] };
- }
- },
-);
-
-// Tool: lock_resume
-server.registerTool(
- "lock_resume",
- {
- title: "Lock Resume",
- description:
- "Lock a resume to prevent edits, patches, or deletion. Useful for protecting finalized resumes from accidental changes.",
- inputSchema: z
- .object({
- id: z.string().min(1).describe("Resume ID to lock. Use list_resumes to find IDs."),
- })
- .strict(),
- },
- async ({ id }: { id: string }) => {
- try {
- await setResumeLocked(id, true);
-
- return {
- content: [
- {
- type: "text",
- text: `Resume (${id}) is now locked. It cannot be edited, patched, or deleted until unlocked.`,
- },
- ],
- };
- } catch (error) {
- const msg = error instanceof Error ? error.message : String(error);
- let hint = "";
- if (msg.includes("404")) hint = " Use list_resumes to find valid IDs.";
- return { isError: true, content: [{ type: "text", text: `Error locking resume: ${msg}${hint}` }] };
- }
- },
-);
-
-// Tool: unlock_resume
-server.registerTool(
- "unlock_resume",
- {
- title: "Unlock Resume",
- description: "Unlock a previously locked resume, allowing edits, patches, and deletion again.",
- inputSchema: z
- .object({
- id: z.string().min(1).describe("Resume ID to unlock. Use list_resumes to find IDs."),
- })
- .strict(),
- },
- async ({ id }: { id: string }) => {
- try {
- await setResumeLocked(id, false);
-
- return {
- content: [{ type: "text", text: `Resume (${id}) is now unlocked. It can be edited, patched, and deleted.` }],
- };
- } catch (error) {
- const msg = error instanceof Error ? error.message : String(error);
- let hint = "";
- if (msg.includes("404")) hint = " Use list_resumes to find valid IDs.";
- return { isError: true, content: [{ type: "text", text: `Error unlocking resume: ${msg}${hint}` }] };
- }
- },
-);
-
-// Resource: resume://{id}
-const resumeTemplate = new ResourceTemplate("resume://{id}", {
- list: async () => {
- const resumes = await listResumes();
-
- return {
- resources: resumes.map((r) => ({
- uri: `resume://${r.id}`,
- name: r.name,
- mimeType: "application/json" as const,
- description: `Resume: ${r.name}`,
- })),
- };
- },
-});
-
-server.registerResource(
- "resume",
- resumeTemplate,
- {
- description: "Full resume data as JSON. Use resume://{id} with ID from list_resumes.",
- mimeType: "application/json",
- },
- async (uri: URL) => {
- const id = uri.href.replace(/^resume:\/\//, "");
- if (!id) throw new Error("Invalid resume URI: resume://{id}");
- const resume = await getResume(id);
- return {
- contents: [{ uri: uri.href, mimeType: "application/json" as const, text: JSON.stringify(resume.data, null, 2) }],
- };
- },
-);
-
-// Resource: resume://schema
-server.registerResource(
- "resume-schema",
- "resume://schema",
- {
- description: "Resume Data JSON Schema for generating correct JSON Patch operations.",
- mimeType: "application/json",
- },
- async (uri: URL) => ({
- contents: [{ uri: uri.href, mimeType: "application/json" as const, text: JSON.stringify(schemaJSON, null, 2) }],
- }),
-);
-
-// --- Transport: stdio (default) ---
-async function runStdio(): Promise {
- const transport = new StdioServerTransport();
- await server.connect(transport);
- console.error("Reactive Resume MCP server running via stdio");
-}
-
-// --- Transport: HTTP (Node http server) ---
-function readBody(req: http.IncomingMessage): Promise {
- return new Promise((resolve, reject) => {
- const chunks: Buffer[] = [];
-
- req.on("data", (chunk) => chunks.push(chunk));
-
- req.on("end", () => {
- const raw = Buffer.concat(chunks).toString("utf8");
- if (!raw.trim()) return resolve(undefined);
-
- try {
- resolve(JSON.parse(raw));
- } catch {
- reject(new Error("Invalid JSON body"));
- }
- });
-
- req.on("error", reject);
- });
-}
-
-async function runHttp(): Promise {
- const { StreamableHTTPServerTransport } = await import("@modelcontextprotocol/sdk/server/streamableHttp.js");
-
- const httpServer = http.createServer(async (req, res) => {
- if (req.method !== "POST" || req.url !== "/mcp") {
- res.writeHead(404, { "Content-Type": "text/plain" });
- res.end("Not Found");
- return;
- }
-
- let body: unknown;
-
- try {
- body = await readBody(req);
- } catch {
- res.writeHead(400, { "Content-Type": "application/json" });
- res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32700, message: "Parse error" }, id: null }));
- return;
- }
-
- const transport = new StreamableHTTPServerTransport({
- sessionIdGenerator: undefined,
- enableJsonResponse: true,
- });
-
- res.on("close", () => transport.close());
-
- await server.connect(transport);
- await transport.handleRequest(req as never, res as never, body);
- });
-
- httpServer.listen(PORT, "127.0.0.1", () => {
- console.error(`Reactive Resume MCP server running on http://127.0.0.1:${PORT}/mcp`);
- });
-}
-
-if (TRANSPORT === "http") {
- runHttp().catch((err) => {
- console.error("Server error:", err);
- process.exit(1);
- });
-} else {
- runStdio().catch((err) => {
- console.error("Server error:", err);
- process.exit(1);
- });
-}
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index d1eaff7ae..98fc42362 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -13,6 +13,7 @@ import { Route as SchemaDotjsonRouteImport } from "./routes/schema[.]json";
import { Route as DashboardRouteRouteImport } from "./routes/dashboard/route";
import { Route as AuthRouteRouteImport } from "./routes/auth/route";
import { Route as HomeRouteRouteImport } from "./routes/_home/route";
+import { Route as McpIndexRouteImport } from "./routes/mcp/index";
import { Route as DashboardIndexRouteImport } from "./routes/dashboard/index";
import { Route as AuthIndexRouteImport } from "./routes/auth/index";
import { Route as HomeIndexRouteImport } from "./routes/_home/index";
@@ -59,6 +60,11 @@ const HomeRouteRoute = HomeRouteRouteImport.update({
id: "/_home",
getParentRoute: () => rootRouteImport,
} as any);
+const McpIndexRoute = McpIndexRouteImport.update({
+ id: "/mcp/",
+ path: "/mcp/",
+ getParentRoute: () => rootRouteImport,
+} as any);
const DashboardIndexRoute = DashboardIndexRouteImport.update({
id: "/",
path: "/",
@@ -213,6 +219,7 @@ export interface FileRoutesByFullPath {
"/printer/$resumeId": typeof PrinterResumeIdRoute;
"/auth/": typeof AuthIndexRoute;
"/dashboard/": typeof DashboardIndexRoute;
+ "/mcp/": typeof McpIndexRoute;
"/api/auth/$": typeof ApiAuthSplatRoute;
"/api/openapi/$": typeof ApiOpenapiSplatRoute;
"/api/rpc/$": typeof ApiRpcSplatRoute;
@@ -241,6 +248,7 @@ export interface FileRoutesByTo {
"/": typeof HomeIndexRoute;
"/auth": typeof AuthIndexRoute;
"/dashboard": typeof DashboardIndexRoute;
+ "/mcp": typeof McpIndexRoute;
"/api/auth/$": typeof ApiAuthSplatRoute;
"/api/openapi/$": typeof ApiOpenapiSplatRoute;
"/api/rpc/$": typeof ApiRpcSplatRoute;
@@ -274,6 +282,7 @@ export interface FileRoutesById {
"/_home/": typeof HomeIndexRoute;
"/auth/": typeof AuthIndexRoute;
"/dashboard/": typeof DashboardIndexRoute;
+ "/mcp/": typeof McpIndexRoute;
"/api/auth/$": typeof ApiAuthSplatRoute;
"/api/openapi/$": typeof ApiOpenapiSplatRoute;
"/api/rpc/$": typeof ApiRpcSplatRoute;
@@ -307,6 +316,7 @@ export interface FileRouteTypes {
| "/printer/$resumeId"
| "/auth/"
| "/dashboard/"
+ | "/mcp/"
| "/api/auth/$"
| "/api/openapi/$"
| "/api/rpc/$"
@@ -335,6 +345,7 @@ export interface FileRouteTypes {
| "/"
| "/auth"
| "/dashboard"
+ | "/mcp"
| "/api/auth/$"
| "/api/openapi/$"
| "/api/rpc/$"
@@ -367,6 +378,7 @@ export interface FileRouteTypes {
| "/_home/"
| "/auth/"
| "/dashboard/"
+ | "/mcp/"
| "/api/auth/$"
| "/api/openapi/$"
| "/api/rpc/$"
@@ -390,6 +402,7 @@ export interface RootRouteChildren {
UsernameSlugRoute: typeof UsernameSlugRoute;
ApiHealthRoute: typeof ApiHealthRoute;
PrinterResumeIdRoute: typeof PrinterResumeIdRoute;
+ McpIndexRoute: typeof McpIndexRoute;
ApiAuthSplatRoute: typeof ApiAuthSplatRoute;
ApiOpenapiSplatRoute: typeof ApiOpenapiSplatRoute;
ApiRpcSplatRoute: typeof ApiRpcSplatRoute;
@@ -426,6 +439,13 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof HomeRouteRouteImport;
parentRoute: typeof rootRouteImport;
};
+ "/mcp/": {
+ id: "/mcp/";
+ path: "/mcp";
+ fullPath: "/mcp/";
+ preLoaderRoute: typeof McpIndexRouteImport;
+ parentRoute: typeof rootRouteImport;
+ };
"/dashboard/": {
id: "/dashboard/";
path: "/";
@@ -696,6 +716,7 @@ const rootRouteChildren: RootRouteChildren = {
UsernameSlugRoute: UsernameSlugRoute,
ApiHealthRoute: ApiHealthRoute,
PrinterResumeIdRoute: PrinterResumeIdRoute,
+ McpIndexRoute: McpIndexRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiOpenapiSplatRoute: ApiOpenapiSplatRoute,
ApiRpcSplatRoute: ApiRpcSplatRoute,
diff --git a/src/routes/mcp/-helpers/prompts.ts b/src/routes/mcp/-helpers/prompts.ts
new file mode 100644
index 000000000..85bc139a4
--- /dev/null
+++ b/src/routes/mcp/-helpers/prompts.ts
@@ -0,0 +1,242 @@
+import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import z from "zod";
+
+// ── Shared prompt helpers ────────────────────────────────────────
+
+const resumeIdArg = z
+ .string()
+ .describe("The ID of the resume. Use `list_resumes` to find IDs, or `create_resume` to create a new one first.");
+
+/** Embeds the resume data and JSON schema as context messages. */
+function resumeContext(id: string) {
+ return [
+ {
+ role: "user" as const,
+ content: {
+ type: "resource" as const,
+ resource: {
+ uri: `resume://${id}`,
+ mimeType: "application/json",
+ text: "Current resume data",
+ },
+ },
+ },
+ {
+ role: "user" as const,
+ content: {
+ type: "resource" as const,
+ resource: {
+ uri: "resume://schema",
+ mimeType: "application/json",
+ text: "Resume data JSON Schema — use this to understand valid paths and types for JSON Patch operations",
+ },
+ },
+ },
+ ];
+}
+
+const PATCH_REFERENCE = [
+ "## JSON Patch Reference",
+ "",
+ "Use the `patch_resume` tool for every change. Common operations:",
+ "",
+ "| Action | Operation |",
+ "|--------|-----------|",
+ '| Change name | `{ "op": "replace", "path": "/basics/name", "value": "Jane Doe" }` |',
+ '| Update headline | `{ "op": "replace", "path": "/basics/headline", "value": "Senior Engineer" }` |',
+ '| Replace summary | `{ "op": "replace", "path": "/summary/content", "value": "Experienced...
" }` |',
+ '| Add experience | `{ "op": "add", "path": "/sections/experience/items/-", "value": { ...full item } }` |',
+ '| Remove skill at index 2 | `{ "op": "remove", "path": "/sections/skills/items/2" }` |',
+ '| Update specific field | `{ "op": "replace", "path": "/sections/experience/items/0/company", "value": "New Corp" }` |',
+ '| Change template | `{ "op": "replace", "path": "/metadata/template", "value": "bronzor" }` |',
+ '| Change primary color | `{ "op": "replace", "path": "/metadata/design/colors/primary", "value": "rgba(37, 99, 235, 1)" }` |',
+ '| Hide a section | `{ "op": "replace", "path": "/sections/interests/hidden", "value": true }` |',
+ "",
+ "Rules:",
+ "- New item IDs must be valid UUIDs (format: `xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx`).",
+ "- HTML content fields (`description`, `summary.content`) must use valid HTML: ``, `
`/`- `, ``, ``.",
+ "- Every `website` field is an object: `{ url: string, label: string }`.",
+ "- Use `get_resume_screenshot` after making visual changes (template, colors, layout) to verify the result.",
+].join("\n");
+
+// ── Prompt Registration ──────────────────────────────────────────
+
+export function registerPrompts(server: McpServer) {
+ // ── Build Resume ─────────────────────────────────────────────
+ server.registerPrompt(
+ "build_resume",
+ {
+ title: "Build Resume",
+ description: "Guide the user step-by-step through building a resume from scratch, section by section.",
+ argsSchema: { id: resumeIdArg },
+ },
+ async ({ id }) => ({
+ messages: [
+ ...resumeContext(id),
+ {
+ role: "user" as const,
+ content: {
+ type: "text" as const,
+ text: [
+ "You are an expert resume writer. Help me build my resume step by step.",
+ "",
+ "## Process",
+ "",
+ "1. **Basics** — Ask for: full name, headline/job title, email, phone, location, website.",
+ "2. **Summary** — Help me write a compelling 2-3 sentence professional summary.",
+ "3. **Experience** — Walk through each role: company, position, period, key accomplishments.",
+ "4. **Education** — Degree, school, graduation date, relevant coursework/honors.",
+ "5. **Skills** — Categorize technical and soft skills with proficiency levels.",
+ "6. **Other sections** — Projects, certifications, languages, volunteer work, etc.",
+ "7. **Design** — Offer to adjust template, typography, and color scheme.",
+ "",
+ "For each section, ask targeted questions, draft the content, and wait for my approval before applying.",
+ "Do NOT fabricate any information — only use what I provide or explicitly ask you to generate.",
+ "",
+ PATCH_REFERENCE,
+ "",
+ "The resume data and schema are attached above. Let's begin!",
+ ].join("\n"),
+ },
+ },
+ ],
+ }),
+ );
+
+ // ── Improve Resume ───────────────────────────────────────────
+ server.registerPrompt(
+ "improve_resume",
+ {
+ title: "Improve Resume",
+ description: "Review resume content and suggest concrete improvements to wording, impact, and structure.",
+ argsSchema: { id: resumeIdArg },
+ },
+ async ({ id }) => ({
+ messages: [
+ ...resumeContext(id),
+ {
+ role: "user" as const,
+ content: {
+ type: "text" as const,
+ text: [
+ "You are an expert resume writer and career coach. Review my resume and help me improve it.",
+ "",
+ "## Analysis Framework",
+ "",
+ "Go through each section and identify:",
+ "- **Weak bullet points** — lacking metrics, impact, or specificity",
+ "- **Passive voice** — replace with strong action verbs (Led, Built, Designed, Increased...)",
+ "- **Vague descriptions** — make concrete with specific technologies, team sizes, outcomes",
+ "- **Missing quantification** — add numbers, percentages, dollar amounts where possible",
+ "- **Structural issues** — inconsistent formatting, poor section ordering, missing sections",
+ "- **Redundancies** — repetitive content that dilutes impact",
+ "",
+ "## Process",
+ "",
+ "1. Start with an **overall assessment** (strengths + key areas to improve).",
+ "2. Work through improvements **one section at a time**.",
+ "3. For each suggestion, explain the **rationale** and show the before/after.",
+ "4. Wait for my **approval** before applying changes via `patch_resume`.",
+ "5. Do NOT fabricate information — suggest improvements based on what exists, ask me for missing details.",
+ "",
+ PATCH_REFERENCE,
+ ].join("\n"),
+ },
+ },
+ ],
+ }),
+ );
+
+ // ── Tailor Resume for a Job ──────────────────────────────────
+ server.registerPrompt(
+ "tailor_resume",
+ {
+ title: "Tailor Resume for a Job",
+ description:
+ "Adapt a resume to match a specific job description by adjusting keywords, content, and structure for ATS optimization.",
+ argsSchema: {
+ id: resumeIdArg,
+ job_description: z.string().min(1).describe("The full job description or posting to tailor the resume for."),
+ },
+ },
+ async ({ id, job_description }) => ({
+ messages: [
+ ...resumeContext(id),
+ {
+ role: "user" as const,
+ content: {
+ type: "text" as const,
+ text: [
+ "You are an expert resume writer specializing in ATS optimization and job targeting.",
+ "",
+ "## Target Job Description",
+ "",
+ job_description,
+ "",
+ "## Process",
+ "",
+ "1. **Gap Analysis** — Extract required skills, qualifications, keywords, and technologies from the job description. Compare against my resume and list what's aligned, what's missing, and what's irrelevant.",
+ "2. **Headline & Summary** — Suggest adjustments to match the target role.",
+ "3. **Experience** — Reword bullet points to highlight relevant accomplishments using keywords from the JD.",
+ "4. **Skills** — Update with missing keywords that I actually possess (ask me to confirm).",
+ "5. **Section Ordering** — Reorder to put the most relevant sections first.",
+ "6. **De-emphasis** — Suggest hiding sections that don't add value for this specific role.",
+ "",
+ "Present a summary of all proposed changes before applying. Apply via `patch_resume` only after I approve.",
+ "Do NOT fabricate experience or skills I don't have — only reframe existing content and ask me about gaps.",
+ "",
+ PATCH_REFERENCE,
+ ].join("\n"),
+ },
+ },
+ ],
+ }),
+ );
+
+ // ── Review Resume ────────────────────────────────────────────
+ server.registerPrompt(
+ "review_resume",
+ {
+ title: "Review Resume",
+ description:
+ "Get a structured, professional critique with a scorecard and prioritized recommendations. Read-only — no changes are made.",
+ argsSchema: { id: resumeIdArg },
+ },
+ async ({ id }) => ({
+ messages: [
+ ...resumeContext(id),
+ {
+ role: "user" as const,
+ content: {
+ type: "text" as const,
+ text: [
+ "You are a professional resume reviewer and career advisor. Provide a thorough critique.",
+ "",
+ "## Evaluation Dimensions (score each 1-10)",
+ "",
+ "| Dimension | What to evaluate |",
+ "|-----------|-----------------|",
+ "| **Completeness** | Are all important sections filled in? Any critical gaps? |",
+ "| **Impact** | Do bullet points demonstrate results with metrics and outcomes? |",
+ "| **Clarity** | Is the writing clear, concise, and free of unnecessary jargon? |",
+ "| **Formatting** | Is the layout consistent? Are sections well-organized? |",
+ "| **ATS Compatibility** | Will it parse well through Applicant Tracking Systems? |",
+ "| **Keywords** | Are relevant industry keywords present and naturally integrated? |",
+ "| **Length** | Is the resume an appropriate length for the experience level? |",
+ "",
+ "## Output Format",
+ "",
+ "1. **Scorecard** — Score each dimension (1-10) with a brief justification.",
+ "2. **Overall Score** — Weighted average of all dimensions.",
+ "3. **Top 5 Recommendations** — Prioritized by impact, with specific actionable suggestions.",
+ "4. **Strengths** — What's working well and should be preserved.",
+ "",
+ "This is a **read-only review**. Do NOT call `patch_resume` or make any changes.",
+ "Format the review as a clear, structured report.",
+ ].join("\n"),
+ },
+ },
+ ],
+ }),
+ );
+}
diff --git a/src/routes/mcp/-helpers/resources.ts b/src/routes/mcp/-helpers/resources.ts
new file mode 100644
index 000000000..63dbefd16
--- /dev/null
+++ b/src/routes/mcp/-helpers/resources.ts
@@ -0,0 +1,92 @@
+import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { client } from "@/integrations/orpc/client";
+import schemaJSON from "@/schema/schema.json";
+
+export function registerResources(server: McpServer) {
+ // ── Resource: resume://{id} ──────────────────────────────────
+ // Dynamic resource that exposes each resume's full data as JSON.
+ // Clients can list all available resumes and read individual ones by ID.
+ const resumeTemplate = new ResourceTemplate("resume://{id}", {
+ list: async () => {
+ const resumes = await client.resume.list();
+
+ return {
+ resources: resumes.map(({ id, name, slug, tags, isPublic, isLocked, updatedAt }) => ({
+ name,
+ title: `${name} (${slug})`,
+ uri: `resume://${id}`,
+ mimeType: "application/json" as const,
+ description: [
+ isPublic ? "Public" : "Private",
+ isLocked ? "Locked" : null,
+ tags.length > 0 ? `Tags: ${tags.join(", ")}` : null,
+ ]
+ .filter(Boolean)
+ .join(" | "),
+ annotations: {
+ lastModified: updatedAt.toISOString(),
+ },
+ })),
+ };
+ },
+ });
+
+ server.registerResource(
+ "resume",
+ resumeTemplate,
+ {
+ title: "Resume Data",
+ mimeType: "application/json",
+ description: [
+ "Full resume data as JSON, including basics, summary, sections, custom sections, and metadata.",
+ "Use resume://{id} with an ID from list_resumes.",
+ "This is also embedded as context in all MCP prompts (build_resume, improve_resume, etc.).",
+ ].join(" "),
+ },
+ async (uri: URL) => {
+ const id = uri.href.replace(/^resume:\/\//, "");
+ if (!id) throw new Error("Invalid resume URI — expected format: resume://{id}");
+
+ const resume = await client.resume.getById({ id });
+
+ return {
+ contents: [
+ {
+ uri: uri.href,
+ mimeType: "application/json" as const,
+ text: JSON.stringify(resume.data, null, 2),
+ },
+ ],
+ };
+ },
+ );
+
+ // ── Resource: resume://schema ────────────────────────────────
+ // Static resource containing the JSON Schema for resume data.
+ // LLMs should reference this when generating JSON Patch operations
+ // to ensure paths and values conform to the expected structure.
+ server.registerResource(
+ "resume-schema",
+ "resume://schema",
+ {
+ title: "Resume Data JSON Schema",
+ mimeType: "application/json",
+ description: [
+ "The JSON Schema describing the complete resume data structure.",
+ "Reference this when generating JSON Patch operations to ensure paths and value types are valid.",
+ "Covers: basics, summary, picture, sections (experience, education, skills, etc.),",
+ "custom sections, and metadata (template, layout, typography, colors, CSS).",
+ ].join(" "),
+ },
+ async (uri: URL) => ({
+ contents: [
+ {
+ uri: uri.href,
+ mimeType: "application/json" as const,
+ text: JSON.stringify(schemaJSON, null, 2),
+ },
+ ],
+ }),
+ );
+}
diff --git a/src/routes/mcp/-helpers/tools.ts b/src/routes/mcp/-helpers/tools.ts
new file mode 100644
index 000000000..6f9eec508
--- /dev/null
+++ b/src/routes/mcp/-helpers/tools.ts
@@ -0,0 +1,432 @@
+import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
+import z from "zod";
+import { client } from "@/integrations/orpc/client";
+import { jsonPatchOperationSchema } from "@/utils/resume/patch";
+
+type PatchOperation = z.infer;
+
+// ── Shared Helpers ──────────────────────────────────────────────
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
+
+function errorHint(error: unknown): string {
+ const msg = errorMessage(error);
+ 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 `unlock_resume` first.";
+ if (msg.includes("404") || msg.includes("not found"))
+ return "\n\nHint: Resume not found. Use `list_resumes` to find valid IDs.";
+ if (msg.includes("400"))
+ return "\n\nHint: Invalid request. Check the input parameters or use `get_resume` to inspect the resume structure.";
+ if (msg.includes("403")) return "\n\nHint: Permission denied. The resume may be locked — use `unlock_resume` 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 }] };
+}
+
+// ── Shared Zod Fragments ────────────────────────────────────────
+
+const resumeIdSchema = z.string().min(1).describe("Resume ID. Use `list_resumes` to find valid IDs.");
+
+// ── Tool Registration ───────────────────────────────────────────
+
+export function registerTools(server: McpServer) {
+ // ── List Resumes ──────────────────────────────────────────────
+ server.registerTool(
+ "list_resumes",
+ {
+ title: "List Resumes",
+ description: [
+ "List all resumes for the authenticated user.",
+ "",
+ "Returns an array of resume objects (without full resume data) containing:",
+ "id, name, slug, tags, isPublic, isLocked, createdAt, updatedAt.",
+ "",
+ "Use this tool first to discover resume IDs before calling other tools.",
+ "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: {
+ readOnlyHint: true,
+ destructiveHint: false,
+ idempotentHint: true,
+ openWorldHint: false,
+ },
+ },
+ 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 `create_resume` to create one.");
+
+ return text(JSON.stringify(resumes, null, 2));
+ },
+ ),
+ );
+
+ // ── Get Resume ────────────────────────────────────────────────
+ server.registerTool(
+ "get_resume",
+ {
+ title: "Get 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 `list_resumes` first to find valid IDs.",
+ "The `resume://schema` resource describes the full data structure.",
+ ].join("\n"),
+ inputSchema: z.object({ id: resumeIdSchema }),
+ annotations: {
+ readOnlyHint: true,
+ destructiveHint: false,
+ idempotentHint: true,
+ openWorldHint: false,
+ },
+ },
+ withErrorHandling("getting resume", async ({ id }: { id: string }) => {
+ const resume = await client.resume.getById({ id });
+
+ return text(JSON.stringify(resume.data, null, 2));
+ }),
+ );
+
+ // ── Create Resume ─────────────────────────────────────────────
+ server.registerTool(
+ "create_resume",
+ {
+ 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 `get_resume` to view or `patch_resume` 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: {
+ readOnlyHint: false,
+ destructiveHint: false,
+ idempotentHint: false,
+ openWorldHint: false,
+ },
+ },
+ 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 \`get_resume\` to view it, or \`patch_resume\` to start editing.`,
+ );
+ },
+ ),
+ );
+
+ // ── Duplicate Resume ──────────────────────────────────────────
+ server.registerTool(
+ "duplicate_resume",
+ {
+ 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: {
+ readOnlyHint: false,
+ destructiveHint: false,
+ idempotentHint: false,
+ openWorldHint: false,
+ },
+ },
+ 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 \`get_resume\` to view it, or \`patch_resume\` to customize.`,
+ );
+ },
+ ),
+ );
+
+ // ── Patch Resume ──────────────────────────────────────────────
+ server.registerTool(
+ "patch_resume",
+ {
+ title: "Patch Resume",
+ description: [
+ "Apply JSON Patch (RFC 6902) operations to partially update a resume's data.",
+ "",
+ "This is the primary way to edit resume content. Use `get_resume` first to inspect the",
+ "current structure, and `resume://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 `unlock_resume` first.",
+ ].join("\n"),
+ inputSchema: z.object({
+ id: resumeIdSchema,
+ operations: z
+ .array(jsonPatchOperationSchema)
+ .min(1)
+ .describe("Array of JSON Patch (RFC 6902) operations to apply"),
+ }),
+ annotations: {
+ readOnlyHint: false,
+ destructiveHint: false,
+ idempotentHint: false,
+ openWorldHint: false,
+ },
+ },
+ 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}`);
+ }),
+ );
+
+ // ── Delete Resume ─────────────────────────────────────────────
+ server.registerTool(
+ "delete_resume",
+ {
+ 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 `unlock_resume` first.",
+ "Consider using `duplicate_resume` to create a backup before deleting.",
+ ].join("\n"),
+ inputSchema: z.object({ id: resumeIdSchema }),
+ annotations: {
+ readOnlyHint: false,
+ destructiveHint: true,
+ idempotentHint: true,
+ openWorldHint: false,
+ },
+ },
+ 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(
+ "lock_resume",
+ {
+ title: "Lock Resume",
+ description: [
+ "Lock a resume to prevent any modifications.",
+ "",
+ "When locked, a resume cannot be edited (patch_resume), updated, or deleted.",
+ "Useful for protecting finalized resumes from accidental changes.",
+ "Use `unlock_resume` to re-enable editing.",
+ ].join("\n"),
+ inputSchema: z.object({ id: resumeIdSchema }),
+ annotations: {
+ readOnlyHint: false,
+ destructiveHint: false,
+ idempotentHint: true,
+ openWorldHint: false,
+ },
+ },
+ 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(
+ "unlock_resume",
+ {
+ title: "Unlock Resume",
+ description: "Unlock a previously locked resume, re-enabling edits, patches, and deletion.",
+ inputSchema: z.object({ id: resumeIdSchema }),
+ annotations: {
+ readOnlyHint: false,
+ destructiveHint: false,
+ idempotentHint: true,
+ openWorldHint: false,
+ },
+ },
+ 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.`);
+ }),
+ );
+
+ // ── Export Resume as PDF ──────────────────────────────────────
+ server.registerTool(
+ "export_resume_pdf",
+ {
+ title: "Export Resume as PDF",
+ description: [
+ "Generate a PDF from the specified resume and return a download URL.",
+ "",
+ "The PDF is rendered using the resume's current template, layout, and design settings,",
+ "then uploaded to storage. The returned URL can be shared or downloaded directly.",
+ "PDF generation may take a few seconds depending on the resume's complexity.",
+ ].join("\n"),
+ inputSchema: z.object({ id: resumeIdSchema }),
+ annotations: {
+ readOnlyHint: true,
+ destructiveHint: false,
+ idempotentHint: true,
+ openWorldHint: false,
+ },
+ },
+ withErrorHandling("exporting resume as PDF", async ({ id }: { id: string }) => {
+ const { url } = await client.printer.printResumeAsPDF({ id });
+
+ return text(`PDF generated successfully.\n\nDownload URL: ${url}`);
+ }),
+ );
+
+ // ── Get Resume Screenshot ────────────────────────────────────
+ server.registerTool(
+ "get_resume_screenshot",
+ {
+ title: "Get Resume Screenshot",
+ description: [
+ "Get a visual preview of the resume's first page as a WebP image URL.",
+ "",
+ "Screenshots are cached for up to 6 hours and regenerated automatically when the resume",
+ "is updated. Returns null if the printer service is unavailable.",
+ "Use this after making changes to visually verify the result.",
+ ].join("\n"),
+ inputSchema: z.object({ id: resumeIdSchema }),
+ annotations: {
+ readOnlyHint: true,
+ destructiveHint: false,
+ idempotentHint: true,
+ openWorldHint: false,
+ },
+ },
+ withErrorHandling("getting resume screenshot", async ({ id }: { id: string }) => {
+ const { url } = await client.printer.getResumeScreenshot({ id });
+
+ if (!url) return text("Screenshot could not be generated. The printer service may be unavailable.");
+
+ return text(
+ `Resume screenshot URL: ${url}\n\nThis is a WebP image of the first page. Open the URL to view the current visual state of the resume.`,
+ );
+ }),
+ );
+
+ // ── Get Resume Statistics ────────────────────────────────────
+ server.registerTool(
+ "get_resume_statistics",
+ {
+ 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: {
+ readOnlyHint: true,
+ destructiveHint: false,
+ idempotentHint: true,
+ openWorldHint: false,
+ },
+ },
+ withErrorHandling("getting resume statistics", async ({ id }: { id: string }) => {
+ const stats = await client.resume.statistics.getById({ id });
+
+ return text(JSON.stringify(stats, null, 2));
+ }),
+ );
+}
diff --git a/src/routes/mcp/index.ts b/src/routes/mcp/index.ts
new file mode 100644
index 000000000..55a5dadf8
--- /dev/null
+++ b/src/routes/mcp/index.ts
@@ -0,0 +1,68 @@
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
+import { createFileRoute } from "@tanstack/react-router";
+import { registerPrompts } from "./-helpers/prompts";
+import { registerResources } from "./-helpers/resources";
+import { registerTools } from "./-helpers/tools";
+
+function createMcpServer() {
+ const server = new McpServer({
+ name: "reactive-resume",
+ version: "1.0.0",
+ title: "Reactive Resume",
+ websiteUrl: "https://rxresu.me",
+ description:
+ "Reactive Resume is a free and open-source resume builder. Use this MCP server to interact with your resume using an LLM of your choice.",
+ icons: [
+ {
+ src: "https://rxresu.me/icon/light.svg",
+ mimeType: "image/svg+xml",
+ theme: "light",
+ },
+ {
+ src: "https://rxresu.me/icon/dark.svg",
+ mimeType: "image/svg+xml",
+ theme: "dark",
+ },
+ ],
+ });
+
+ registerResources(server);
+ registerTools(server);
+ registerPrompts(server);
+
+ return server;
+}
+
+export const Route = createFileRoute("/mcp/")({
+ server: {
+ handlers: {
+ ANY: async ({ request }) => {
+ try {
+ const apiKey = request.headers.get("x-api-key");
+ if (!apiKey) throw new Error("Unauthorized");
+
+ const server = createMcpServer();
+ const transport = new WebStandardStreamableHTTPServerTransport({
+ enableJsonResponse: true,
+ });
+
+ await server.connect(transport);
+
+ return await transport.handleRequest(request);
+ } catch (error) {
+ console.error(`Error handling request: ${error instanceof Error ? error.message : String(error)}`);
+
+ return Response.json({
+ id: null,
+ jsonrpc: "2.0",
+ error: {
+ code: -32603,
+ message: `Error handling request: ${error instanceof Error ? error.message : String(error)}`,
+ },
+ });
+ }
+ },
+ },
+ },
+});