building a better mcp server

This commit is contained in:
Amruth Pillai
2026-02-09 23:09:14 +01:00
parent 8e060890d3
commit 833b8343ac
7 changed files with 945 additions and 509 deletions
+90 -75
View File
@@ -13,95 +13,113 @@ The [Model Context Protocol (MCP)](https://modelcontextprotocol.io) is a standar
<Steps>
<Step title="Create an API key">
Follow the [Using the API](/guides/using-the-api) guide to create an API key in your Reactive Resume dashboard.
</Step>
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.
<Step title="Install Node.js">
The MCP server requires [Node.js](https://nodejs.org) version 18 or later.
</Step>
<Step title="Install dependencies">
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).
</Step>
</Steps>
## 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"
]
}
}
}
```
<Info>
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.
</Info>
### 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 (110 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`)
<Tip>
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.
</Tip>
## 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` |
-434
View File
@@ -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<T>(method: string, path: string, body?: unknown): Promise<T> {
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<T>;
}
async function listResumes(): Promise<RouterOutput["resume"]["list"]> {
return apiRequest("GET", "/api/openapi/resumes");
}
async function getResume(id: string): Promise<RouterOutput["resume"]["getById"]> {
return apiRequest("GET", `/api/openapi/resumes/${encodeURIComponent(id)}`);
}
async function patchResume(id: string, operations: Operation[]): Promise<RouterOutput["resume"]["patch"]> {
return apiRequest("PATCH", `/api/openapi/resumes/${encodeURIComponent(id)}`, { operations });
}
async function createResume(input: RouterInput["resume"]["create"]): Promise<RouterOutput["resume"]["create"]> {
return apiRequest("POST", "/api/openapi/resumes", input);
}
async function deleteResume(id: string): Promise<RouterOutput["resume"]["delete"]> {
return apiRequest("DELETE", `/api/openapi/resumes/${encodeURIComponent(id)}`);
}
async function setResumeLocked(id: string, isLocked: boolean): Promise<RouterOutput["resume"]["setLocked"]> {
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<void> {
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<unknown> {
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<void> {
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);
});
}
+21
View File
@@ -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,
+242
View File
@@ -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": "<p>Experienced...</p>" }` |',
'| 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: `<p>`, `<ul>`/`<li>`, `<strong>`, `<em>`.",
"- 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"),
},
},
],
}),
);
}
+92
View File
@@ -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),
},
],
}),
);
}
+432
View File
@@ -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<typeof jsonPatchOperationSchema>;
// ── 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<T>(label: string, handler: (params: T) => Promise<CallToolResult>) {
return async (params: T): Promise<CallToolResult> => {
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));
}),
);
}
+68
View File
@@ -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)}`,
},
});
}
},
},
},
});