mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-13 22:37:14 +10:00
building a better mcp server
This commit is contained in:
@@ -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 (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`)
|
||||
|
||||
<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
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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)}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user