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)}`, + }, + }); + } + }, + }, + }, +});