better mcp server

This commit is contained in:
Amruth Pillai
2026-04-09 00:28:31 +02:00
parent 06b9da39ed
commit 1810dc8b07
14 changed files with 1831 additions and 1502 deletions
+1
View File
@@ -2,6 +2,7 @@ dist
.env*
/data
.nitro
.agents
.output
.vercel
.cursor
-3
View File
@@ -19,8 +19,5 @@
"ui": "@/components/ui",
"lib": "@/utils",
"hooks": "@/hooks"
},
"registries": {
"@animate-ui": "https://animate-ui.com/r/{name}.json"
}
}
+42 -22
View File
@@ -207,32 +207,52 @@ If you're running a self-hosted Reactive Resume instance, replace `https://rxres
## Available Tools
The MCP server exposes the following tools:
Tool names use a hierarchical `reactive_resume.*` prefix ([SEP-986](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/986) style) so they stay distinct when multiple MCP servers are enabled in the same client.
| Tool | Description |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `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 |
| `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 |
| Tool | Description |
| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `reactive_resume.list_resumes` | List all resumes with IDs, names, tags, and status. Supports filtering by tags and sorting by last updated, creation date, or name |
| `reactive_resume.get_resume` | Get the full data of a specific resume by ID |
| `reactive_resume.create_resume` | Create a new, empty resume with a name and slug. Optionally pre-fill with sample data |
| `reactive_resume.duplicate_resume` | Create a copy of an existing resume with a new name and slug |
| `reactive_resume.patch_resume` | Apply JSON Patch (RFC 6902) operations to modify a resume's data |
| `reactive_resume.delete_resume` | Permanently delete a resume and all associated files. **Irreversible** |
| `reactive_resume.lock_resume` | Lock a resume to prevent edits, patches, and deletion |
| `reactive_resume.unlock_resume` | Unlock a previously locked resume to re-enable editing |
| `reactive_resume.export_resume_pdf` | Generate a PDF from the resume and return a download URL |
| `reactive_resume.get_resume_screenshot` | Get a visual preview of the resume's first page as a WebP image URL |
| `reactive_resume.get_resume_statistics` | Get view and download statistics for a resume |
### Breaking change (tool names)
Older clients may refer to unprefixed names (`list_resumes`, `get_resume`, …). Those names are no longer used; update automations and saved prompts to the `reactive_resume.*` names above.
## Available Resources
| Resource | Description |
| ----------------- | --------------------------------------------------------------------------------------------------------------- |
| `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 |
Resources follow MCP conventions: **static** items appear in `resources/list`; **parameterized** access is declared in `resources/templates/list` and read via `resources/read` once you know the ID.
| Discovery | What you get |
| ------------------------------------- | --------------------------------------------------------------------------------------------- |
| `resources/list` | Static resources only — currently **`resume://_meta/schema`** (ResumeData JSON Schema) |
| `resources/templates/list` | **`resume://{id}`** — template for reading full resume JSON by ID (not enumerated per resume) |
| `reactive_resume.list_resumes` (tool) | **Primary way to discover resume IDs** — resumes are not listed as separate MCP resources |
| URI | Description |
| ----------------------- | ------------------------------------------------------------------------ |
| `resume://_meta/schema` | ResumeData JSON Schema — use for valid JSON Patch paths and value types |
| `resume://{id}` | Full resume data as JSON — use an ID from `reactive_resume.list_resumes` |
### Breaking change (schema URI)
The schema resource was previously `resume://schema`. It is now **`resume://_meta/schema`**. Update any saved prompts, automations, or client configs that referenced the old URI.
### Static server card (`/.well-known/mcp/server-card.json`)
`GET /.well-known/mcp/server-card.json` returns a JSON document ([SEP-1649](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1649)) with `serverInfo`, optional authentication metadata, and summaries of tools, resources, resource templates, and prompts. It is generated to match the live MCP server and can be used for discovery when a client cannot run a full capability scan against `/mcp/`.
## 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.
Prompts are pre-built workflows that provide the AI with structured instructions and context. Each prompt embeds the resume data and the schema resource (`resume://_meta/schema`) automatically.
| Prompt | Description |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
@@ -286,8 +306,8 @@ Once your MCP client is connected, you can use natural language to interact with
- "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.
The AI will use `reactive_resume.get_resume` to inspect your current resume before making changes with
`reactive_resume.patch_resume`. This ensures the correct JSON paths are used.
</Tip>
## Troubleshooting
@@ -297,7 +317,7 @@ Once your MCP client is connected, you can use natural language to interact with
| "Unauthorized" with no login prompt | Your client may not support MCP OAuth discovery. Use API key mode (`x-api-key`) |
| OAuth login opens but fails redirect/callback | Confirm your client's MCP OAuth callback settings and retry the connection |
| "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 (404)" | The resume ID doesn't exist. Use `reactive_resume.list_resumes` to find valid IDs |
| "API error (403)" | The resume is locked. Unlock it in the Reactive Resume dashboard |
| 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` |
+37 -36
View File
@@ -35,22 +35,22 @@
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.66",
"@ai-sdk/google": "^3.0.58",
"@ai-sdk/openai": "^3.0.50",
"@aws-sdk/client-s3": "^3.1024.0",
"@ai-sdk/anthropic": "^3.0.68",
"@ai-sdk/google": "^3.0.60",
"@ai-sdk/openai": "^3.0.52",
"@aws-sdk/client-s3": "^3.1027.0",
"@base-ui/react": "^1.3.0",
"@better-auth/api-key": "^1.5.6",
"@better-auth/drizzle-adapter": "^1.5.6",
"@better-auth/infra": "^0.1.13",
"@better-auth/oauth-provider": "^1.5.6",
"@better-auth/api-key": "^1.6.1",
"@better-auth/drizzle-adapter": "^1.6.1",
"@better-auth/infra": "^0.1.14",
"@better-auth/oauth-provider": "^1.6.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/ibm-plex-sans": "^5.2.8",
"@hookform/resolvers": "^5.2.2",
"@lingui/core": "^5.9.4",
"@lingui/react": "^5.9.4",
"@lingui/core": "^5.9.5",
"@lingui/react": "^5.9.5",
"@modelcontextprotocol/sdk": "^1.29.0",
"@monaco-editor/react": "4.8.0-rc.3",
"@orpc/client": "^1.13.13",
@@ -68,24 +68,24 @@
"@tanstack/react-router-ssr-query": "^1.166.10",
"@tanstack/react-start": "^1.167.16",
"@tanstack/zod-adapter": "^1.166.9",
"@tiptap/extension-highlight": "^3.22.2",
"@tiptap/extension-table": "^3.22.2",
"@tiptap/extension-text-align": "^3.22.2",
"@tiptap/pm": "^3.22.2",
"@tiptap/react": "^3.22.2",
"@tiptap/starter-kit": "^3.22.2",
"@uiw/color-convert": "^2.9.6",
"@uiw/react-color-colorful": "^2.9.6",
"ai": "^6.0.146",
"ai-sdk-ollama": "^3.8.2",
"@tiptap/extension-highlight": "^3.22.3",
"@tiptap/extension-table": "^3.22.3",
"@tiptap/extension-text-align": "^3.22.3",
"@tiptap/pm": "^3.22.3",
"@tiptap/react": "^3.22.3",
"@tiptap/starter-kit": "^3.22.3",
"@uiw/color-convert": "^2.10.1",
"@uiw/react-color-colorful": "^2.10.1",
"ai": "^6.0.154",
"ai-sdk-ollama": "^3.8.3",
"bcrypt": "^6.0.0",
"better-auth": "^1.5.6",
"better-auth": "^1.6.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"docx": "^9.6.1",
"dompurify": "^3.3.3",
"drizzle-orm": "1.0.0-beta.20",
"drizzle-orm": "1.0.0-beta.21",
"drizzle-zod": "1.0.0-beta.14-a36c63d",
"es-toolkit": "^1.45.1",
"fast-deep-equal": "^3.1.3",
@@ -96,18 +96,18 @@
"jsonrepair": "^3.13.3",
"monaco-editor": "^0.55.1",
"motion": "^12.38.0",
"nodemailer": "^8.0.4",
"nodemailer": "^8.0.5",
"pg": "^8.20.0",
"puppeteer-core": "^24.40.0",
"qrcode.react": "^4.2.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-hook-form": "^7.72.1",
"react-hotkeys-hook": "^5.2.4",
"react-resizable-panels": "^4.9.0",
"react-window": "^2.2.7",
"react-zoom-pan-pinch": "^3.7.0",
"shadcn": "^4.1.2",
"react-zoom-pan-pinch": "^4.0.3",
"shadcn": "^4.2.0",
"sharp": "^0.34.5",
"sonner": "^2.0.7",
"srvx": "^0.11.15",
@@ -125,9 +125,9 @@
},
"devDependencies": {
"@babel/core": "^7.29.0",
"@lingui/babel-plugin-lingui-macro": "^5.9.4",
"@lingui/cli": "^5.9.4",
"@lingui/vite-plugin": "^5.9.4",
"@lingui/babel-plugin-lingui-macro": "^5.9.5",
"@lingui/cli": "^5.9.5",
"@lingui/vite-plugin": "^5.9.5",
"@rolldown/plugin-babel": "^0.2.2",
"@tailwindcss/vite": "^4.2.2",
"@testing-library/react": "^16.3.2",
@@ -138,22 +138,22 @@
"@types/pg": "^8.20.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript/native-preview": "7.0.0-dev.20260404.1",
"@typescript/native-preview": "7.0.0-dev.20260408.1",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.2",
"@vitest/coverage-v8": "^4.1.3",
"babel-plugin-macros": "^3.1.0",
"drizzle-kit": "1.0.0-beta.20",
"drizzle-kit": "1.0.0-beta.21",
"happy-dom": "^20.8.9",
"jose": "^6.2.2",
"knip": "^6.3.0",
"knip": "^6.3.1",
"nitro": "3.0.260311-beta",
"node-addon-api": "^8.7.0",
"node-gyp": "^12.2.0",
"npm-check-updates": "^20.0.0",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.1.15",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.1.16",
"vite-plugin-pwa": "^1.2.0",
"vite-plus": "latest",
"vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.15"
"vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.16"
},
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319",
"pnpm": {
@@ -175,6 +175,7 @@
"bcrypt",
"esbuild",
"msw",
"prisma",
"sharp"
]
}
+1323 -1342
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -169,7 +169,6 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
if (!isAIEnabled)
throw new Error(t`This feature requires AI Integration to be enabled. Please enable it in the settings.`);
// const arrayBuffer = await values.file.arrayBuffer();
const base64 = await fileToBase64(values.file);
const mediaType =
+14 -10
View File
@@ -1,3 +1,5 @@
import type { ModelMessage } from "ai";
import { createAnthropic } from "@ai-sdk/anthropic";
import { createGoogleGenerativeAI } from "@ai-sdk/google";
import { createOpenAI } from "@ai-sdk/openai";
@@ -248,30 +250,32 @@ type ParsePdfInput = z.infer<typeof aiCredentialsSchema> & {
file: z.infer<typeof fileInputSchema>;
};
type BuildResumeParsingMessagesInput = {
systemPrompt: string;
userPrompt: string;
file: z.infer<typeof fileInputSchema>;
mediaType: string;
};
function buildResumeParsingMessages({
systemPrompt,
userPrompt,
file,
mediaType,
}: {
systemPrompt: string;
userPrompt: string;
file: z.infer<typeof fileInputSchema>;
mediaType: string;
}) {
}: BuildResumeParsingMessagesInput): ModelMessage[] {
return [
{
role: "system" as const,
role: "system",
content:
systemPrompt +
"\n\nIMPORTANT: You must return ONLY raw valid JSON. Do not return markdown, do not return explanations. Just the JSON object. Use the following JSON as a template and fill in the extracted values. For arrays, you MUST use the exact key names shown in the template (e.g. use 'description' instead of 'summary', 'website' instead of 'url'):\n\n" +
JSON.stringify(aiExtractionTemplate, null, 2),
},
{
role: "user" as const,
role: "user",
content: [
{ type: "text" as const, text: userPrompt },
{ type: "file" as const, data: file.data, mediaType, filename: file.name },
{ type: "text", text: userPrompt },
{ type: "file", data: file.data, mediaType, filename: file.name },
],
},
];
+22
View File
@@ -47,6 +47,7 @@ import { Route as ApiOpenapiSplatRouteImport } from "./routes/api/openapi.$";
import { Route as ApiAuthSplatRouteImport } from "./routes/api/auth.$";
import { Route as DotwellKnownOauthProtectedResourceSplatRouteImport } from "./routes/[.]well-known/oauth-protected-resource.$";
import { Route as DotwellKnownOauthAuthorizationServerSplatRouteImport } from "./routes/[.]well-known/oauth-authorization-server.$";
import { Route as DotwellKnownMcpServerCardDotjsonRouteImport } from "./routes/[.]well-known/mcp/server-card[.]json";
import { Route as DashboardSettingsAuthenticationIndexRouteImport } from "./routes/dashboard/settings/authentication/index";
const SchemaDotjsonRoute = SchemaDotjsonRouteImport.update({
@@ -248,6 +249,12 @@ const DotwellKnownOauthAuthorizationServerSplatRoute =
path: "/$",
getParentRoute: () => DotwellKnownOauthAuthorizationServerRoute,
} as any);
const DotwellKnownMcpServerCardDotjsonRoute =
DotwellKnownMcpServerCardDotjsonRouteImport.update({
id: "/.well-known/mcp/server-card.json",
path: "/.well-known/mcp/server-card.json",
getParentRoute: () => rootRouteImport,
} as any);
const DashboardSettingsAuthenticationIndexRoute =
DashboardSettingsAuthenticationIndexRouteImport.update({
id: "/settings/authentication/",
@@ -278,6 +285,7 @@ export interface FileRoutesByFullPath {
"/auth/": typeof AuthIndexRoute;
"/dashboard/": typeof DashboardIndexRoute;
"/mcp/": typeof McpIndexRoute;
"/.well-known/mcp/server-card.json": typeof DotwellKnownMcpServerCardDotjsonRoute;
"/.well-known/oauth-authorization-server/$": typeof DotwellKnownOauthAuthorizationServerSplatRoute;
"/.well-known/oauth-protected-resource/$": typeof DotwellKnownOauthProtectedResourceSplatRoute;
"/api/auth/$": typeof ApiAuthSplatRoute;
@@ -315,6 +323,7 @@ export interface FileRoutesByTo {
"/auth": typeof AuthIndexRoute;
"/dashboard": typeof DashboardIndexRoute;
"/mcp": typeof McpIndexRoute;
"/.well-known/mcp/server-card.json": typeof DotwellKnownMcpServerCardDotjsonRoute;
"/.well-known/oauth-authorization-server/$": typeof DotwellKnownOauthAuthorizationServerSplatRoute;
"/.well-known/oauth-protected-resource/$": typeof DotwellKnownOauthProtectedResourceSplatRoute;
"/api/auth/$": typeof ApiAuthSplatRoute;
@@ -357,6 +366,7 @@ export interface FileRoutesById {
"/auth/": typeof AuthIndexRoute;
"/dashboard/": typeof DashboardIndexRoute;
"/mcp/": typeof McpIndexRoute;
"/.well-known/mcp/server-card.json": typeof DotwellKnownMcpServerCardDotjsonRoute;
"/.well-known/oauth-authorization-server/$": typeof DotwellKnownOauthAuthorizationServerSplatRoute;
"/.well-known/oauth-protected-resource/$": typeof DotwellKnownOauthProtectedResourceSplatRoute;
"/api/auth/$": typeof ApiAuthSplatRoute;
@@ -399,6 +409,7 @@ export interface FileRouteTypes {
| "/auth/"
| "/dashboard/"
| "/mcp/"
| "/.well-known/mcp/server-card.json"
| "/.well-known/oauth-authorization-server/$"
| "/.well-known/oauth-protected-resource/$"
| "/api/auth/$"
@@ -436,6 +447,7 @@ export interface FileRouteTypes {
| "/auth"
| "/dashboard"
| "/mcp"
| "/.well-known/mcp/server-card.json"
| "/.well-known/oauth-authorization-server/$"
| "/.well-known/oauth-protected-resource/$"
| "/api/auth/$"
@@ -477,6 +489,7 @@ export interface FileRouteTypes {
| "/auth/"
| "/dashboard/"
| "/mcp/"
| "/.well-known/mcp/server-card.json"
| "/.well-known/oauth-authorization-server/$"
| "/.well-known/oauth-protected-resource/$"
| "/api/auth/$"
@@ -508,6 +521,7 @@ export interface RootRouteChildren {
ApiHealthRoute: typeof ApiHealthRoute;
PrinterResumeIdRoute: typeof PrinterResumeIdRoute;
McpIndexRoute: typeof McpIndexRoute;
DotwellKnownMcpServerCardDotjsonRoute: typeof DotwellKnownMcpServerCardDotjsonRoute;
ApiAuthSplatRoute: typeof ApiAuthSplatRoute;
ApiOpenapiSplatRoute: typeof ApiOpenapiSplatRoute;
ApiRpcSplatRoute: typeof ApiRpcSplatRoute;
@@ -782,6 +796,13 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof DotwellKnownOauthAuthorizationServerSplatRouteImport;
parentRoute: typeof DotwellKnownOauthAuthorizationServerRoute;
};
"/.well-known/mcp/server-card.json": {
id: "/.well-known/mcp/server-card.json";
path: "/.well-known/mcp/server-card.json";
fullPath: "/.well-known/mcp/server-card.json";
preLoaderRoute: typeof DotwellKnownMcpServerCardDotjsonRouteImport;
parentRoute: typeof rootRouteImport;
};
"/dashboard/settings/authentication/": {
id: "/dashboard/settings/authentication/";
path: "/settings/authentication";
@@ -919,6 +940,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiHealthRoute: ApiHealthRoute,
PrinterResumeIdRoute: PrinterResumeIdRoute,
McpIndexRoute: McpIndexRoute,
DotwellKnownMcpServerCardDotjsonRoute: DotwellKnownMcpServerCardDotjsonRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiOpenapiSplatRoute: ApiOpenapiSplatRoute,
ApiRpcSplatRoute: ApiRpcSplatRoute,
@@ -0,0 +1,18 @@
import { createFileRoute } from "@tanstack/react-router";
import { buildMcpServerCard } from "@/routes/mcp/-helpers/mcp-server-card";
/** Well-known MCP server card (SEP-1649) for static metadata when clients cannot complete a full capability scan. */
export const Route = createFileRoute("/.well-known/mcp/server-card.json")({
server: {
handlers: {
GET: async () =>
Response.json(buildMcpServerCard(__APP_VERSION__), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=60, stale-while-revalidate=120",
},
}),
},
},
});
+272
View File
@@ -0,0 +1,272 @@
import { toJsonSchemaCompat } from "@modelcontextprotocol/sdk/server/zod-json-schema-compat.js";
import z from "zod";
import { jsonPatchOperationSchema } from "@/utils/resume/patch";
import { MCP_TOOL_NAME as T } from "./tools";
const resumeId = z.string().min(1).describe("Resume ID.");
/**
* Static MCP server card (SEP-1649 / well-known `mcp/server-card.json`).
* Kept in sync with `registerTools`, `registerResources`, and `registerPrompts`.
*/
export function buildMcpServerCard(appVersion: string) {
const tools = [
{
name: T.listResumes,
title: "List Resumes",
description: [
"Primary way to discover resume IDs for this account. Resumes are not listed as MCP resources;",
"use this tool (not `resources/list`) to enumerate IDs.",
"",
"Returns an array of resume objects (without full resume data) containing:",
"id, name, slug, tags, isPublic, isLocked, createdAt, updatedAt.",
"",
`Call this before \`${T.getResume}\`, \`${T.patchResume}\`, prompts, or \`resources/read\` with \`resume://{id}\`.`,
"Results can be filtered by tags and sorted by last updated date, creation date, or name.",
].join("\n"),
inputSchema: toJsonSchemaCompat(
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."),
}),
),
},
{
name: T.getResume,
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 \`${T.listResumes}\` first to find valid IDs.`,
"The `resume://_meta/schema` resource describes the full data structure for JSON Patch paths.",
].join("\n"),
inputSchema: toJsonSchemaCompat(z.object({ id: resumeId })),
},
{
name: T.createResume,
title: "Create Resume",
description: [
"Create a new, empty resume with a name and URL-friendly slug.",
"",
"Returns the ID of the newly created resume.",
"Set `withSampleData` to true to pre-fill with example content (useful for testing).",
`After creating, use \`${T.getResume}\` to view or \`${T.patchResume}\` to populate it.`,
].join("\n"),
inputSchema: toJsonSchemaCompat(
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."),
}),
),
},
{
name: T.duplicateResume,
title: "Duplicate Resume",
description: [
"Create a copy of an existing resume with all its data.",
"",
"Returns the ID of the newly duplicated resume.",
"You must provide a new name and slug for the copy.",
"Useful for creating job-specific variants of a base resume.",
].join("\n"),
inputSchema: toJsonSchemaCompat(
z.object({
id: resumeId.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"),
}),
),
},
{
name: T.patchResume,
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 \`${T.getResume}\` first to inspect the`,
"current structure, and `resume://_meta/schema` to understand valid paths and types.",
"",
"Supported operations: add, remove, replace, move, copy, test.",
].join("\n"),
inputSchema: toJsonSchemaCompat(
z.object({
id: resumeId,
operations: z
.array(jsonPatchOperationSchema)
.min(1)
.describe("Array of JSON Patch (RFC 6902) operations to apply"),
}),
),
},
{
name: T.deleteResume,
title: "Delete Resume",
description: [
"Permanently delete a resume and all its associated files (screenshots, PDFs).",
"",
`This action is IRREVERSIBLE. Locked resumes cannot be deleted — use \`${T.unlockResume}\` first.`,
`Consider using \`${T.duplicateResume}\` to create a backup before deleting.`,
].join("\n"),
inputSchema: toJsonSchemaCompat(z.object({ id: resumeId })),
},
{
name: T.lockResume,
title: "Lock Resume",
description: [
"Lock a resume to prevent any modifications.",
"",
`When locked, a resume cannot be edited (${T.patchResume}), updated, or deleted.`,
`Use \`${T.unlockResume}\` to re-enable editing.`,
].join("\n"),
inputSchema: toJsonSchemaCompat(z.object({ id: resumeId })),
},
{
name: T.unlockResume,
title: "Unlock Resume",
description: "Unlock a previously locked resume, re-enabling edits, patches, and deletion.",
inputSchema: toJsonSchemaCompat(z.object({ id: resumeId })),
},
{
name: T.exportResumePdf,
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.",
].join("\n"),
inputSchema: toJsonSchemaCompat(z.object({ id: resumeId })),
},
{
name: T.getResumeScreenshot,
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.",
].join("\n"),
inputSchema: toJsonSchemaCompat(z.object({ id: resumeId })),
},
{
name: T.getResumeStatistics,
title: "Get Resume Statistics",
description: [
"Get view and download statistics for a resume.",
"",
"Returns: isPublic (boolean), views (count), downloads (count),",
"lastViewedAt (timestamp or null), lastDownloadedAt (timestamp or null).",
].join("\n"),
inputSchema: toJsonSchemaCompat(z.object({ id: resumeId })),
},
];
const prompts = [
{
name: "build_resume",
title: "Build Resume",
description: "Guide the user step-by-step through building a resume from scratch, section by section.",
arguments: [{ name: "id", description: "Resume ID.", required: true }],
},
{
name: "improve_resume",
title: "Improve Resume",
description: "Review resume content and suggest concrete improvements to wording, impact, and structure.",
arguments: [{ name: "id", description: "Resume ID.", required: true }],
},
{
name: "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.",
arguments: [
{ name: "id", description: "Resume ID.", required: true },
{
name: "job_description",
description: "The full job description or posting to tailor the resume for.",
required: true,
},
],
},
{
name: "review_resume",
title: "Review Resume",
description:
"Get a structured, professional critique with a scorecard and prioritized recommendations. Read-only — no changes are made.",
arguments: [{ name: "id", description: "Resume ID.", required: true }],
},
];
const resources = [
{
name: "resume-schema",
title: "Resume Data JSON Schema",
uri: "resume://_meta/schema",
description: [
"The JSON Schema describing the complete resume data structure.",
"Reference when generating JSON Patch operations so paths and value types are valid.",
].join(" "),
mimeType: "application/json",
},
];
const resourceTemplates = [
{
name: "resume",
title: "Resume Data",
uriTemplate: "resume://{id}",
description: "Full resume data as JSON. Discover IDs with the list tool; read via resources/read or get_resume.",
mimeType: "application/json",
},
];
return {
serverInfo: {
name: "reactive-resume",
version: appVersion,
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" as const },
{ src: "https://rxresu.me/icon/dark.svg", mimeType: "image/svg+xml", theme: "dark" as const },
],
},
tools,
prompts,
resources,
resourceTemplates,
authentication: {
required: true,
schemes: ["oauth2", "bearer"],
},
};
}
+11 -7
View File
@@ -2,11 +2,15 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import z from "zod";
import { MCP_TOOL_NAME as T } from "./tools";
// ── 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.");
.describe(
`The ID of the resume. Use \`${T.listResumes}\` to find IDs, or \`${T.createResume}\` to create a new one first.`,
);
/** Embeds the resume data and JSON schema as context messages. */
function resumeContext(id: string) {
@@ -27,7 +31,7 @@ function resumeContext(id: string) {
content: {
type: "resource" as const,
resource: {
uri: "resume://schema",
uri: "resume://_meta/schema",
mimeType: "application/json",
text: "Resume data JSON Schema — use this to understand valid paths and types for JSON Patch operations",
},
@@ -39,7 +43,7 @@ function resumeContext(id: string) {
const PATCH_REFERENCE = [
"## JSON Patch Reference",
"",
"Use the `patch_resume` tool for every change. Common operations:",
`Use the \`${T.patchResume}\` tool for every change. Common operations:`,
"",
"| Action | Operation |",
"|--------|-----------|",
@@ -57,7 +61,7 @@ const PATCH_REFERENCE = [
"- 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.",
`- Use \`${T.getResumeScreenshot}\` after making visual changes (template, colors, layout) to verify the result.`,
].join("\n");
// ── Prompt Registration ──────────────────────────────────────────
@@ -137,7 +141,7 @@ export function registerPrompts(server: McpServer) {
"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`.",
`4. Wait for my **approval** before applying changes via \`${T.patchResume}\`.`,
"5. Do NOT fabricate information — suggest improvements based on what exists, ask me for missing details.",
"",
PATCH_REFERENCE,
@@ -183,7 +187,7 @@ export function registerPrompts(server: McpServer) {
"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.",
`Present a summary of all proposed changes before applying. Apply via \`${T.patchResume}\` 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,
@@ -232,7 +236,7 @@ export function registerPrompts(server: McpServer) {
"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.",
`This is a **read-only review**. Do NOT call \`${T.patchResume}\` or make any changes.`,
"Format the review as a clear, structured report.",
].join("\n"),
},
+9 -30
View File
@@ -5,34 +5,12 @@ import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { client } from "@/integrations/orpc/client";
import schemaJSON from "@/schema/schema.json";
import { MCP_TOOL_NAME as T } from "./tools";
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(),
},
})),
};
},
});
// Template resource: read resume JSON by ID. Discovery is via list tool (tools), not resources/list.
const resumeTemplate = new ResourceTemplate("resume://{id}", { list: undefined });
server.registerResource(
"resume",
@@ -42,8 +20,9 @@ export function registerResources(server: McpServer) {
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.).",
`Discover resume IDs with the \`${T.listResumes}\` tool, then read \`resume://{id}\` or use \`${T.getResume}\`.`,
"Appears in `resources/templates/list`; not enumerated in `resources/list`.",
"Embedded as context in MCP prompts (build_resume, improve_resume, etc.).",
].join(" "),
},
async (uri: URL) => {
@@ -64,13 +43,13 @@ export function registerResources(server: McpServer) {
},
);
// ── Resource: resume://schema ────────────────────────────────
// ── Resource: resume://_meta/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",
"resume://_meta/schema",
{
title: "Resume Data JSON Schema",
mimeType: "application/json",
+51 -31
View File
@@ -8,6 +8,21 @@ import { jsonPatchOperationSchema } from "@/utils/resume/patch";
type PatchOperation = z.infer<typeof jsonPatchOperationSchema>;
/** Hierarchical MCP tool names (SEP-986-style namespacing). */
export const MCP_TOOL_NAME = {
listResumes: "reactive_resume.list_resumes",
getResume: "reactive_resume.get_resume",
createResume: "reactive_resume.create_resume",
duplicateResume: "reactive_resume.duplicate_resume",
patchResume: "reactive_resume.patch_resume",
deleteResume: "reactive_resume.delete_resume",
lockResume: "reactive_resume.lock_resume",
unlockResume: "reactive_resume.unlock_resume",
exportResumePdf: "reactive_resume.export_resume_pdf",
getResumeScreenshot: "reactive_resume.get_resume_screenshot",
getResumeStatistics: "reactive_resume.get_resume_statistics",
} as const;
// ── Shared Helpers ──────────────────────────────────────────────
function errorMessage(error: unknown): string {
@@ -16,13 +31,15 @@ function errorMessage(error: unknown): string {
function errorHint(error: unknown): string {
const msg = errorMessage(error);
const { unlockResume, listResumes, getResume } = MCP_TOOL_NAME;
if (msg.includes("slug already exists")) return "\n\nHint: The slug is already in use. Try a different one.";
if (msg.includes("locked")) return "\n\nHint: This resume is locked. Use `unlock_resume` first.";
if (msg.includes("locked")) return `\n\nHint: This resume is locked. Use \`${unlockResume}\` first.`;
if (msg.includes("404") || msg.includes("not found"))
return "\n\nHint: Resume not found. Use `list_resumes` to find valid IDs.";
return `\n\nHint: Resume not found. Use \`${listResumes}\` to find valid IDs.`;
if (msg.includes("400"))
return "\n\nHint: Invalid request. Check the input parameters or use `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 `\n\nHint: Invalid request. Check the input parameters or use \`${getResume}\` to inspect the resume structure.`;
if (msg.includes("403"))
return `\n\nHint: Permission denied. The resume may be locked — use \`${unlockResume}\` first.`;
return "";
}
@@ -50,23 +67,26 @@ function text(value: string): CallToolResult {
// ── Shared Zod Fragments ────────────────────────────────────────
const resumeIdSchema = z.string().min(1).describe("Resume ID. Use `list_resumes` to find valid IDs.");
const T = MCP_TOOL_NAME;
const resumeIdSchema = z.string().min(1).describe(`Resume ID. Use \`${T.listResumes}\` to find valid IDs.`);
// ── Tool Registration ───────────────────────────────────────────
export function registerTools(server: McpServer) {
// ── List Resumes ──────────────────────────────────────────────
server.registerTool(
"list_resumes",
T.listResumes,
{
title: "List Resumes",
description: [
"List all resumes for the authenticated user.",
"Primary way to discover resume IDs for this account. Resumes are not listed as MCP resources;",
"use this tool (not `resources/list`) to enumerate IDs.",
"",
"Returns an array of resume objects (without full resume data) containing:",
"id, name, slug, tags, isPublic, isLocked, createdAt, updatedAt.",
"",
"Use this tool first to discover resume IDs before calling other tools.",
`Call this before \`${T.getResume}\`, \`${T.patchResume}\`, prompts, or \`resources/read\` with \`resume://{id}\`.`,
"Results can be filtered by tags and sorted by last updated date, creation date, or name.",
].join("\n"),
inputSchema: z.object({
@@ -95,7 +115,7 @@ export function registerTools(server: McpServer) {
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.");
if (resumes.length === 0) return text(`No resumes found. Use \`${T.createResume}\` to create one.`);
return text(JSON.stringify(resumes, null, 2));
},
@@ -104,7 +124,7 @@ export function registerTools(server: McpServer) {
// ── Get Resume ────────────────────────────────────────────────
server.registerTool(
"get_resume",
T.getResume,
{
title: "Get Resume",
description: [
@@ -114,8 +134,8 @@ export function registerTools(server: McpServer) {
"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.",
`Use \`${T.listResumes}\` first to find valid IDs.`,
"The `resume://_meta/schema` resource describes the full data structure for JSON Patch paths.",
].join("\n"),
inputSchema: z.object({ id: resumeIdSchema }),
annotations: {
@@ -134,7 +154,7 @@ export function registerTools(server: McpServer) {
// ── Create Resume ─────────────────────────────────────────────
server.registerTool(
"create_resume",
T.createResume,
{
title: "Create Resume",
description: [
@@ -142,7 +162,7 @@ export function registerTools(server: McpServer) {
"",
"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.",
`After creating, use \`${T.getResume}\` to view or \`${T.patchResume}\` to populate it.`,
].join("\n"),
inputSchema: z.object({
name: z.string().min(1).max(64).describe("Display name for the resume (e.g. 'Software Engineer 2026')"),
@@ -181,7 +201,7 @@ export function registerTools(server: McpServer) {
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.`,
`Created resume "${name}" (ID: ${id}) with slug "${slug}".${withSampleData ? " Pre-filled with sample data." : ""}\n\nNext steps: Use \`${T.getResume}\` to view it, or \`${T.patchResume}\` to start editing.`,
);
},
),
@@ -189,7 +209,7 @@ export function registerTools(server: McpServer) {
// ── Duplicate Resume ──────────────────────────────────────────
server.registerTool(
"duplicate_resume",
T.duplicateResume,
{
title: "Duplicate Resume",
description: [
@@ -218,7 +238,7 @@ export function registerTools(server: McpServer) {
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.`,
`Duplicated resume as "${name}" (ID: ${newId}) with slug "${slug}".\n\nNext steps: Use \`${T.getResume}\` to view it, or \`${T.patchResume}\` to customize.`,
);
},
),
@@ -226,14 +246,14 @@ export function registerTools(server: McpServer) {
// ── Patch Resume ──────────────────────────────────────────────
server.registerTool(
"patch_resume",
T.patchResume,
{
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.",
`This is the primary way to edit resume content. Use \`${T.getResume}\` first to inspect the`,
"current structure, and `resume://_meta/schema` to understand valid paths and types.",
"",
"Supported operations: add, remove, replace, move, copy, test.",
"",
@@ -250,7 +270,7 @@ export function registerTools(server: McpServer) {
"",
"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.",
`Locked resumes cannot be patched — use \`${T.unlockResume}\` first.`,
].join("\n"),
inputSchema: z.object({
id: resumeIdSchema,
@@ -276,14 +296,14 @@ export function registerTools(server: McpServer) {
// ── Delete Resume ─────────────────────────────────────────────
server.registerTool(
"delete_resume",
T.deleteResume,
{
title: "Delete Resume",
description: [
"Permanently delete a resume and all its associated files (screenshots, PDFs).",
"",
"This action is IRREVERSIBLE. Locked resumes cannot be deleted — use `unlock_resume` first.",
"Consider using `duplicate_resume` to create a backup before deleting.",
`This action is IRREVERSIBLE. Locked resumes cannot be deleted — use \`${T.unlockResume}\` first.`,
`Consider using \`${T.duplicateResume}\` to create a backup before deleting.`,
].join("\n"),
inputSchema: z.object({ id: resumeIdSchema }),
annotations: {
@@ -302,15 +322,15 @@ export function registerTools(server: McpServer) {
// ── Lock Resume ───────────────────────────────────────────────
server.registerTool(
"lock_resume",
T.lockResume,
{
title: "Lock Resume",
description: [
"Lock a resume to prevent any modifications.",
"",
"When locked, a resume cannot be edited (patch_resume), updated, or deleted.",
`When locked, a resume cannot be edited (${T.patchResume}), updated, or deleted.`,
"Useful for protecting finalized resumes from accidental changes.",
"Use `unlock_resume` to re-enable editing.",
`Use \`${T.unlockResume}\` to re-enable editing.`,
].join("\n"),
inputSchema: z.object({ id: resumeIdSchema }),
annotations: {
@@ -329,7 +349,7 @@ export function registerTools(server: McpServer) {
// ── Unlock Resume ─────────────────────────────────────────────
server.registerTool(
"unlock_resume",
T.unlockResume,
{
title: "Unlock Resume",
description: "Unlock a previously locked resume, re-enabling edits, patches, and deletion.",
@@ -350,7 +370,7 @@ export function registerTools(server: McpServer) {
// ── Export Resume as PDF ──────────────────────────────────────
server.registerTool(
"export_resume_pdf",
T.exportResumePdf,
{
title: "Export Resume as PDF",
description: [
@@ -377,7 +397,7 @@ export function registerTools(server: McpServer) {
// ── Get Resume Screenshot ────────────────────────────────────
server.registerTool(
"get_resume_screenshot",
T.getResumeScreenshot,
{
title: "Get Resume Screenshot",
description: [
@@ -408,7 +428,7 @@ export function registerTools(server: McpServer) {
// ── Get Resume Statistics ────────────────────────────────────
server.registerTool(
"get_resume_statistics",
T.getResumeStatistics,
{
title: "Get Resume Statistics",
description: [
+31 -20
View File
@@ -9,26 +9,37 @@ 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",
},
],
});
const server = new McpServer(
{
name: "reactive-resume",
version: __APP_VERSION__,
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",
},
],
},
{
instructions: [
"You are connected to Reactive Resume over MCP.",
"Authenticate with OAuth (recommended) or an API key (`x-api-key`).",
"Discover resume IDs with `reactive_resume.list_resumes` (not `resources/list`).",
"Read schema at `resume://_meta/schema`; read resume JSON via `resume://{id}` or `reactive_resume.get_resume`.",
"Apply edits with JSON Patch through `reactive_resume.patch_resume`.",
].join(" "),
},
);
registerResources(server);
registerTools(server);