mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-13 06:24:54 +10:00
refactor(mcp): derive tool metadata from single TOOL_META record
- Extract one TOOL_META record (title/description/inputSchema/annotations per tool) consumed by both registerTools and buildMcpServerCard, eliminating the ~200-line duplication in the server card. - Collapse TOOL_ANNOTATIONS from 14 × 4-line inline objects to 5 named annotation-preset consts (READ_IDEMPOTENT, WRITE_NON_IDEMPOTENT, etc.), saving ~55 lines. Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
This commit is contained in:
@@ -1,10 +1,6 @@
|
||||
import { toJsonSchemaCompat } from "@modelcontextprotocol/sdk/server/zod-json-schema-compat.js";
|
||||
import z from "zod";
|
||||
import { resumePatchOperationsSchema } from "@reactive-resume/ai/tools/resume-tool-contracts";
|
||||
import { MCP_TOOL_NAME as T } from "./mcp-tool-names";
|
||||
import { TOOL_ANNOTATIONS } from "./tool-annotations";
|
||||
|
||||
const resumeId = z.string().min(1).describe("Resume ID.");
|
||||
import { TOOL_META } from "./tool-meta";
|
||||
|
||||
/**
|
||||
* Static MCP server card (SEP-1649 / well-known `mcp/server-card.json`).
|
||||
@@ -14,248 +10,14 @@ const resumeId = z.string().min(1).describe("Resume ID.");
|
||||
* The parameterized resume URI is therefore duplicated here so discovery matches the live template.
|
||||
*/
|
||||
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."),
|
||||
}),
|
||||
),
|
||||
annotations: TOOL_ANNOTATIONS[T.listResumes],
|
||||
},
|
||||
{
|
||||
name: T.listResumeTags,
|
||||
title: "List Resume Tags",
|
||||
description: [
|
||||
"Returns a sorted list of every distinct tag used across your resumes.",
|
||||
"Useful for choosing tag filters when calling list tools or keeping naming consistent.",
|
||||
].join("\n"),
|
||||
inputSchema: toJsonSchemaCompat(z.object({})),
|
||||
annotations: TOOL_ANNOTATIONS[T.listResumeTags],
|
||||
},
|
||||
{
|
||||
name: T.getResume,
|
||||
title: "Read 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 })),
|
||||
annotations: TOOL_ANNOTATIONS[T.getResume],
|
||||
},
|
||||
{
|
||||
name: T.getResumeAnalysis,
|
||||
title: "Get Resume Analysis",
|
||||
description: [
|
||||
"Returns the latest saved AI analysis for a resume (scorecard, strengths, suggestions), if any.",
|
||||
"Analyses are created from the Reactive Resume web app AI flow, not from MCP.",
|
||||
`Returns JSON or a short message if none exists. Use \`${T.listResumes}\` to find resume IDs.`,
|
||||
].join("\n"),
|
||||
inputSchema: toJsonSchemaCompat(z.object({ id: resumeId })),
|
||||
annotations: TOOL_ANNOTATIONS[T.getResumeAnalysis],
|
||||
},
|
||||
{
|
||||
name: T.downloadResumePdf,
|
||||
title: "Download Resume PDF",
|
||||
description: [
|
||||
"Create a short-lived authenticated URL for downloading a resume as a PDF.",
|
||||
"The URL expires in 10 minutes and should be used immediately.",
|
||||
"Returns JSON containing: resumeId, name, downloadUrl, expiresAt, expiresInSeconds, contentType.",
|
||||
`Use \`${T.listResumes}\` first to find valid IDs.`,
|
||||
].join("\n"),
|
||||
inputSchema: toJsonSchemaCompat(z.object({ id: resumeId })),
|
||||
annotations: TOOL_ANNOTATIONS[T.downloadResumePdf],
|
||||
},
|
||||
{
|
||||
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."),
|
||||
}),
|
||||
),
|
||||
annotations: TOOL_ANNOTATIONS[T.createResume],
|
||||
},
|
||||
{
|
||||
name: T.importResume,
|
||||
title: "Import Resume",
|
||||
description: [
|
||||
"Create a new resume from a full ResumeData JSON object (e.g. an exported file from Reactive Resume).",
|
||||
"A random name and slug are assigned automatically, like the web importer.",
|
||||
`For small edits to an existing resume, prefer \`${T.patchResume}\` instead of re-importing.`,
|
||||
"Large payloads may exceed MCP client message limits — in that case, use the web UI or the HTTP API.",
|
||||
].join("\n"),
|
||||
inputSchema: toJsonSchemaCompat(
|
||||
z.object({
|
||||
data: z
|
||||
.unknown()
|
||||
.describe("Complete ResumeData JSON (same shape as read_resume output or `resume://_meta/schema`)."),
|
||||
}),
|
||||
),
|
||||
annotations: TOOL_ANNOTATIONS[T.importResume],
|
||||
},
|
||||
{
|
||||
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"),
|
||||
}),
|
||||
),
|
||||
annotations: TOOL_ANNOTATIONS[T.duplicateResume],
|
||||
},
|
||||
{
|
||||
name: T.patchResume,
|
||||
title: "Apply Resume Patch",
|
||||
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: resumePatchOperationsSchema,
|
||||
}),
|
||||
),
|
||||
annotations: TOOL_ANNOTATIONS[T.patchResume],
|
||||
},
|
||||
{
|
||||
name: T.updateResume,
|
||||
title: "Update Resume (metadata)",
|
||||
description: [
|
||||
"Update resume metadata only: display name, URL slug, tags, and/or public visibility.",
|
||||
"Does not change section content — use JSON Patch via the patch tool for body edits.",
|
||||
`Locked resumes cannot be updated; use \`${T.unlockResume}\` first.`,
|
||||
"Password protection cannot be set or removed via MCP; use the web app for that.",
|
||||
"",
|
||||
"Always returns your canonical share URL (`{app}/{username}/{slug}`). Anonymous viewers can use it only when `isPublic` is true; password protection from the web app still applies.",
|
||||
].join("\n"),
|
||||
inputSchema: toJsonSchemaCompat(
|
||||
z.object({
|
||||
id: resumeId,
|
||||
name: z.string().min(1).max(64).optional().describe("Display name for the resume."),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.optional()
|
||||
.describe("URL-friendly slug; must stay unique among your resumes."),
|
||||
tags: z.array(z.string()).optional().describe("Replace the resume's tags (omit to leave unchanged)."),
|
||||
isPublic: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"When true, anyone with the link can view the public resume (subject to password if set in the app).",
|
||||
),
|
||||
}),
|
||||
),
|
||||
annotations: TOOL_ANNOTATIONS[T.updateResume],
|
||||
},
|
||||
{
|
||||
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 })),
|
||||
annotations: TOOL_ANNOTATIONS[T.deleteResume],
|
||||
},
|
||||
{
|
||||
name: T.lockResume,
|
||||
title: "Lock Resume",
|
||||
description: [
|
||||
"Lock a resume to prevent any modifications.",
|
||||
"",
|
||||
`When locked, a resume cannot be edited (${T.patchResume}, ${T.updateResume}), or deleted.`,
|
||||
`Use \`${T.unlockResume}\` to re-enable editing.`,
|
||||
].join("\n"),
|
||||
inputSchema: toJsonSchemaCompat(z.object({ id: resumeId })),
|
||||
annotations: TOOL_ANNOTATIONS[T.lockResume],
|
||||
},
|
||||
{
|
||||
name: T.unlockResume,
|
||||
title: "Unlock Resume",
|
||||
description: "Unlock a previously locked resume, re-enabling edits, patches, and deletion.",
|
||||
inputSchema: toJsonSchemaCompat(z.object({ id: resumeId })),
|
||||
annotations: TOOL_ANNOTATIONS[T.unlockResume],
|
||||
},
|
||||
{
|
||||
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 })),
|
||||
annotations: TOOL_ANNOTATIONS[T.getResumeStatistics],
|
||||
},
|
||||
];
|
||||
// ponytail: derived from TOOL_META; title/description/inputSchema/annotations declared once
|
||||
const tools = Object.entries(TOOL_META).map(([name, { title, description, inputSchema, annotations }]) => ({
|
||||
name,
|
||||
title,
|
||||
description,
|
||||
inputSchema: toJsonSchemaCompat(inputSchema),
|
||||
annotations,
|
||||
}));
|
||||
|
||||
const prompts = [
|
||||
{
|
||||
|
||||
@@ -3,90 +3,52 @@ import { MCP_TOOL_NAME } from "./mcp-tool-names";
|
||||
|
||||
type McpRegisteredToolName = (typeof MCP_TOOL_NAME)[keyof typeof MCP_TOOL_NAME];
|
||||
|
||||
// ponytail: 5 distinct annotation shapes shared across 14 tools
|
||||
const READ_IDEMPOTENT: ToolAnnotations = {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
};
|
||||
const READ_NON_IDEMPOTENT: ToolAnnotations = {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
};
|
||||
const WRITE_NON_IDEMPOTENT: ToolAnnotations = {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
};
|
||||
const WRITE_DESTRUCTIVE: ToolAnnotations = {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
};
|
||||
const WRITE_IDEMPOTENT: ToolAnnotations = {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
};
|
||||
|
||||
/** Tool behavior hints for MCP `tools/list` and the static server card. */
|
||||
export const TOOL_ANNOTATIONS: Record<McpRegisteredToolName, ToolAnnotations> = {
|
||||
[MCP_TOOL_NAME.listResumes]: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
[MCP_TOOL_NAME.listResumeTags]: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
[MCP_TOOL_NAME.getResume]: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
[MCP_TOOL_NAME.getResumeAnalysis]: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
[MCP_TOOL_NAME.downloadResumePdf]: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
[MCP_TOOL_NAME.createResume]: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
[MCP_TOOL_NAME.importResume]: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
[MCP_TOOL_NAME.duplicateResume]: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
[MCP_TOOL_NAME.patchResume]: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
[MCP_TOOL_NAME.updateResume]: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
[MCP_TOOL_NAME.deleteResume]: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
[MCP_TOOL_NAME.lockResume]: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
[MCP_TOOL_NAME.unlockResume]: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
[MCP_TOOL_NAME.getResumeStatistics]: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
[MCP_TOOL_NAME.listResumes]: READ_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.listResumeTags]: READ_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.getResume]: READ_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.getResumeAnalysis]: READ_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.downloadResumePdf]: READ_NON_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.createResume]: WRITE_NON_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.importResume]: WRITE_NON_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.duplicateResume]: WRITE_NON_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.patchResume]: WRITE_NON_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.updateResume]: WRITE_NON_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.deleteResume]: WRITE_DESTRUCTIVE,
|
||||
[MCP_TOOL_NAME.lockResume]: WRITE_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.unlockResume]: WRITE_IDEMPOTENT,
|
||||
[MCP_TOOL_NAME.getResumeStatistics]: READ_IDEMPOTENT,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Canonical tool metadata (title, description, inputSchema, annotations) declared once.
|
||||
* Consumed by both `registerTools` (raw Zod) and `buildMcpServerCard` (toJsonSchemaCompat).
|
||||
*/
|
||||
import z from "zod";
|
||||
import { resumePatchOperationsSchema } from "@reactive-resume/ai/tools/resume-tool-contracts";
|
||||
import { MCP_TOOL_NAME as T } from "./mcp-tool-names";
|
||||
import { TOOL_ANNOTATIONS } from "./tool-annotations";
|
||||
|
||||
// ponytail: shared schema fragment; exported so server-card can re-use without re-importing
|
||||
export const resumeIdSchema = z.string().min(1).describe(`Resume ID. Use \`${T.listResumes}\` to find valid IDs.`);
|
||||
|
||||
export const TOOL_META = {
|
||||
[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: 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: TOOL_ANNOTATIONS[T.listResumes],
|
||||
},
|
||||
[T.listResumeTags]: {
|
||||
title: "List Resume Tags",
|
||||
description: [
|
||||
"Returns a sorted list of every distinct tag used across your resumes.",
|
||||
"Useful for choosing tag filters when calling list tools or keeping naming consistent.",
|
||||
].join("\n"),
|
||||
inputSchema: z.object({}),
|
||||
annotations: TOOL_ANNOTATIONS[T.listResumeTags],
|
||||
},
|
||||
[T.getResume]: {
|
||||
title: "Read 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: z.object({ id: resumeIdSchema }),
|
||||
annotations: TOOL_ANNOTATIONS[T.getResume],
|
||||
},
|
||||
[T.getResumeAnalysis]: {
|
||||
title: "Get Resume Analysis",
|
||||
description: [
|
||||
"Returns the latest saved AI analysis for a resume (scorecard, strengths, suggestions), if any.",
|
||||
"Analyses are created from the Reactive Resume web app AI flow, not from MCP.",
|
||||
`Returns JSON or a short message if none exists. Use \`${T.listResumes}\` to find resume IDs.`,
|
||||
].join("\n"),
|
||||
inputSchema: z.object({ id: resumeIdSchema }),
|
||||
annotations: TOOL_ANNOTATIONS[T.getResumeAnalysis],
|
||||
},
|
||||
[T.downloadResumePdf]: {
|
||||
title: "Download Resume PDF",
|
||||
description: [
|
||||
"Create a short-lived authenticated URL for downloading a resume as a PDF.",
|
||||
"The URL expires in 10 minutes and should be used immediately.",
|
||||
"Returns JSON containing: resumeId, name, downloadUrl, expiresAt, expiresInSeconds, contentType.",
|
||||
`Use \`${T.listResumes}\` first to find valid IDs.`,
|
||||
].join("\n"),
|
||||
inputSchema: z.object({ id: resumeIdSchema }),
|
||||
annotations: TOOL_ANNOTATIONS[T.downloadResumePdf],
|
||||
},
|
||||
[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: 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: TOOL_ANNOTATIONS[T.createResume],
|
||||
},
|
||||
[T.importResume]: {
|
||||
title: "Import Resume",
|
||||
description: [
|
||||
"Create a new resume from a full ResumeData JSON object (e.g. an exported file from Reactive Resume).",
|
||||
"A random name and slug are assigned automatically, like the web importer.",
|
||||
`For small edits to an existing resume, prefer \`${T.patchResume}\` instead of re-importing.`,
|
||||
"Large payloads may exceed MCP client message limits — in that case, use the web UI or the HTTP API.",
|
||||
].join("\n"),
|
||||
inputSchema: z.object({
|
||||
data: z
|
||||
.unknown()
|
||||
.describe("Complete ResumeData JSON (same shape as `read_resume` body or `resume://_meta/schema`)."),
|
||||
}),
|
||||
annotations: TOOL_ANNOTATIONS[T.importResume],
|
||||
},
|
||||
[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: 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: TOOL_ANNOTATIONS[T.duplicateResume],
|
||||
},
|
||||
[T.patchResume]: {
|
||||
title: "Apply Resume Patch",
|
||||
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.",
|
||||
"",
|
||||
"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 \`${T.unlockResume}\` first.`,
|
||||
].join("\n"),
|
||||
inputSchema: z.object({
|
||||
id: resumeIdSchema,
|
||||
operations: resumePatchOperationsSchema,
|
||||
}),
|
||||
annotations: TOOL_ANNOTATIONS[T.patchResume],
|
||||
},
|
||||
[T.updateResume]: {
|
||||
title: "Update Resume (metadata)",
|
||||
description: [
|
||||
"Update resume metadata only: display name, URL slug, tags, and/or public visibility.",
|
||||
"Does not change section content — use JSON Patch via the patch tool for body edits.",
|
||||
`Locked resumes cannot be updated; use \`${T.unlockResume}\` first.`,
|
||||
"Password protection cannot be set or removed via MCP; use the web app for that.",
|
||||
"",
|
||||
"Always returns your canonical share URL (`{app}/{username}/{slug}`). Anonymous viewers can use it only when `isPublic` is true; password protection from the web app still applies.",
|
||||
].join("\n"),
|
||||
inputSchema: z.object({
|
||||
id: resumeIdSchema,
|
||||
name: z.string().min(1).max(64).optional().describe("Display name for the resume."),
|
||||
slug: z.string().min(1).max(64).optional().describe("URL-friendly slug; must stay unique among your resumes."),
|
||||
tags: z.array(z.string()).optional().describe("Replace the resume's tags (omit to leave unchanged)."),
|
||||
isPublic: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"When true, anyone with the link can view the public resume (subject to password if set in the app).",
|
||||
),
|
||||
}),
|
||||
annotations: TOOL_ANNOTATIONS[T.updateResume],
|
||||
},
|
||||
[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: z.object({ id: resumeIdSchema }),
|
||||
annotations: TOOL_ANNOTATIONS[T.deleteResume],
|
||||
},
|
||||
[T.lockResume]: {
|
||||
title: "Lock Resume",
|
||||
description: [
|
||||
"Lock a resume to prevent any modifications.",
|
||||
"",
|
||||
`When locked, a resume cannot be edited (${T.patchResume}, ${T.updateResume}), or deleted.`,
|
||||
"Useful for protecting finalized resumes from accidental changes.",
|
||||
`Use \`${T.unlockResume}\` to re-enable editing.`,
|
||||
].join("\n"),
|
||||
inputSchema: z.object({ id: resumeIdSchema }),
|
||||
annotations: TOOL_ANNOTATIONS[T.lockResume],
|
||||
},
|
||||
[T.unlockResume]: {
|
||||
title: "Unlock Resume",
|
||||
description: "Unlock a previously locked resume, re-enabling edits, patches, and deletion.",
|
||||
inputSchema: z.object({ id: resumeIdSchema }),
|
||||
annotations: TOOL_ANNOTATIONS[T.unlockResume],
|
||||
},
|
||||
[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: z.object({ id: resumeIdSchema }),
|
||||
annotations: TOOL_ANNOTATIONS[T.getResumeStatistics],
|
||||
},
|
||||
} as const;
|
||||
+39
-249
@@ -1,24 +1,21 @@
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { RouterClient } from "@orpc/server";
|
||||
import type { resumePatchOperationsSchema } from "@reactive-resume/ai/tools/resume-tool-contracts";
|
||||
import type router from "@reactive-resume/api/routers";
|
||||
import z from "zod";
|
||||
import { resumePatchOperationsSchema } from "@reactive-resume/ai/tools/resume-tool-contracts";
|
||||
import type z from "zod";
|
||||
import { resolveUserFromRequestHeaders } from "@reactive-resume/api/context";
|
||||
import {
|
||||
createResumePdfDownloadUrl,
|
||||
MAX_PDF_DOWNLOAD_URL_TTL_SECONDS,
|
||||
} from "@reactive-resume/api/features/resume/export";
|
||||
import { createResumePdfDownloadUrl } from "@reactive-resume/api/features/resume/export";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { MCP_TOOL_NAME } from "./mcp-tool-names";
|
||||
import { TOOL_ANNOTATIONS } from "./tool-annotations";
|
||||
import { TOOL_META } from "./tool-meta";
|
||||
|
||||
export { MCP_TOOL_NAME } from "./mcp-tool-names";
|
||||
|
||||
type PatchOperation = z.infer<typeof resumePatchOperationsSchema>[number];
|
||||
|
||||
// ── Shared Helpers ──────────────────────────────────────────────
|
||||
// ── Shared Helpers ───────────────���──────────────────────────────
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
@@ -79,46 +76,17 @@ function resumeShareUrlNotes(input: { isPublic: boolean; hasPassword: boolean })
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ── Shared Zod Fragments ────────────────────────────────────────
|
||||
// ── Shared Zod Fragments ─────────────��──────────────────────────
|
||||
|
||||
const T = MCP_TOOL_NAME;
|
||||
|
||||
const resumeIdSchema = z.string().min(1).describe(`Resume ID. Use \`${T.listResumes}\` to find valid IDs.`);
|
||||
|
||||
// ── Tool Registration ───────────────────────────────────────────
|
||||
// ── Tool Registration ────────────────────���──────────────────────
|
||||
|
||||
export function registerTools(server: McpServer, client: RouterClient<typeof router>, requestHeaders: Headers) {
|
||||
// ── List Resumes ──────────────────────────────────────────────
|
||||
// ── List Resumes ──────────────────���───────────────────────────
|
||||
server.registerTool(
|
||||
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: 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: TOOL_ANNOTATIONS[T.listResumes],
|
||||
},
|
||||
TOOL_META[T.listResumes],
|
||||
withErrorHandling(
|
||||
"listing resumes",
|
||||
async ({ tags, sort }: { tags: string[]; sort: "lastUpdatedAt" | "createdAt" | "name" }) => {
|
||||
@@ -131,18 +99,10 @@ export function registerTools(server: McpServer, client: RouterClient<typeof rou
|
||||
),
|
||||
);
|
||||
|
||||
// ── List Resume Tags ──────────────────────────────────────────
|
||||
// ── List Resume Tags ───────────────────���──────────────────────
|
||||
server.registerTool(
|
||||
T.listResumeTags,
|
||||
{
|
||||
title: "List Resume Tags",
|
||||
description: [
|
||||
"Returns a sorted list of every distinct tag used across your resumes.",
|
||||
"Useful for choosing tag filters when calling list tools or keeping naming consistent.",
|
||||
].join("\n"),
|
||||
inputSchema: z.object({}),
|
||||
annotations: TOOL_ANNOTATIONS[T.listResumeTags],
|
||||
},
|
||||
TOOL_META[T.listResumeTags],
|
||||
withErrorHandling("listing resume tags", async () => {
|
||||
const tags = await client.resume.tags.list();
|
||||
|
||||
@@ -152,24 +112,10 @@ export function registerTools(server: McpServer, client: RouterClient<typeof rou
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Read Resume ───────────────────────────────────────────────
|
||||
// ── Read Resume ────────────────���──────────────────────────────
|
||||
server.registerTool(
|
||||
T.getResume,
|
||||
{
|
||||
title: "Read 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: z.object({ id: resumeIdSchema }),
|
||||
annotations: TOOL_ANNOTATIONS[T.getResume],
|
||||
},
|
||||
TOOL_META[T.getResume],
|
||||
withErrorHandling("getting resume", async ({ id }: { id: string }) => {
|
||||
const resume = await client.resume.getById({ id });
|
||||
|
||||
@@ -177,19 +123,10 @@ export function registerTools(server: McpServer, client: RouterClient<typeof rou
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Get Resume Analysis ───────────────────────────────────────
|
||||
// ── Get Resume Analysis ────────────────────���──────────────────
|
||||
server.registerTool(
|
||||
T.getResumeAnalysis,
|
||||
{
|
||||
title: "Get Resume Analysis",
|
||||
description: [
|
||||
"Returns the latest saved AI analysis for a resume (scorecard, strengths, suggestions), if any.",
|
||||
"Analyses are created from the Reactive Resume web app AI flow, not from MCP.",
|
||||
`Returns JSON or a short message if none exists. Use \`${T.listResumes}\` to find resume IDs.`,
|
||||
].join("\n"),
|
||||
inputSchema: z.object({ id: resumeIdSchema }),
|
||||
annotations: TOOL_ANNOTATIONS[T.getResumeAnalysis],
|
||||
},
|
||||
TOOL_META[T.getResumeAnalysis],
|
||||
withErrorHandling("getting resume analysis", async ({ id }: { id: string }) => {
|
||||
const analysis = await client.resume.analysis.getById({ id });
|
||||
|
||||
@@ -199,20 +136,10 @@ export function registerTools(server: McpServer, client: RouterClient<typeof rou
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Download Resume PDF ───────────────────────────────────────
|
||||
// ── Download Resume PDF ────────��──────────────────────────────
|
||||
server.registerTool(
|
||||
T.downloadResumePdf,
|
||||
{
|
||||
title: "Download Resume PDF",
|
||||
description: [
|
||||
"Create a short-lived authenticated URL for downloading a resume as a PDF.",
|
||||
`The URL expires in ${MAX_PDF_DOWNLOAD_URL_TTL_SECONDS / 60} minutes and should be used immediately.`,
|
||||
"Returns JSON containing: resumeId, name, downloadUrl, expiresAt, expiresInSeconds, contentType.",
|
||||
`Use \`${T.listResumes}\` first to find valid IDs.`,
|
||||
].join("\n"),
|
||||
inputSchema: z.object({ id: resumeIdSchema }),
|
||||
annotations: TOOL_ANNOTATIONS[T.downloadResumePdf],
|
||||
},
|
||||
TOOL_META[T.downloadResumePdf],
|
||||
withErrorHandling("creating PDF download URL", async ({ id }: { id: string }) => {
|
||||
const resume = await client.resume.getById({ id });
|
||||
const user = await resolveUserFromRequestHeaders(requestHeaders);
|
||||
@@ -240,31 +167,7 @@ export function registerTools(server: McpServer, client: RouterClient<typeof rou
|
||||
// ── Create Resume ─────────────────────────────────────────────
|
||||
server.registerTool(
|
||||
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: 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: TOOL_ANNOTATIONS[T.createResume],
|
||||
},
|
||||
TOOL_META[T.createResume],
|
||||
withErrorHandling(
|
||||
"creating resume",
|
||||
async ({
|
||||
@@ -287,24 +190,10 @@ export function registerTools(server: McpServer, client: RouterClient<typeof rou
|
||||
),
|
||||
);
|
||||
|
||||
// ── Import Resume ─────────────────────────────────────────────
|
||||
// ── Import Resume ─────────────��───────────────────────────────
|
||||
server.registerTool(
|
||||
T.importResume,
|
||||
{
|
||||
title: "Import Resume",
|
||||
description: [
|
||||
"Create a new resume from a full ResumeData JSON object (e.g. an exported file from Reactive Resume).",
|
||||
"A random name and slug are assigned automatically, like the web importer.",
|
||||
`For small edits to an existing resume, prefer \`${T.patchResume}\` instead of re-importing.`,
|
||||
"Large payloads may exceed MCP client message limits — in that case, use the web UI or the HTTP API.",
|
||||
].join("\n"),
|
||||
inputSchema: z.object({
|
||||
data: z
|
||||
.unknown()
|
||||
.describe("Complete ResumeData JSON (same shape as `read_resume` body or `resume://_meta/schema`)."),
|
||||
}),
|
||||
annotations: TOOL_ANNOTATIONS[T.importResume],
|
||||
},
|
||||
TOOL_META[T.importResume],
|
||||
withErrorHandling("importing resume", async ({ data }: { data: unknown }) => {
|
||||
const parsed = resumeDataSchema.safeParse(data);
|
||||
if (!parsed.success)
|
||||
@@ -326,26 +215,10 @@ export function registerTools(server: McpServer, client: RouterClient<typeof rou
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Duplicate Resume ──────────────────────────────────────────
|
||||
// ── Duplicate Resume ────────────────────���─────────────────────
|
||||
server.registerTool(
|
||||
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: 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: TOOL_ANNOTATIONS[T.duplicateResume],
|
||||
},
|
||||
TOOL_META[T.duplicateResume],
|
||||
withErrorHandling(
|
||||
"duplicating resume",
|
||||
async ({ id, name, slug, tags }: { id: string; name: string; slug: string; tags: string[] }) => {
|
||||
@@ -361,37 +234,7 @@ export function registerTools(server: McpServer, client: RouterClient<typeof rou
|
||||
// ── Apply Resume Patch ────────────────────────────────────────
|
||||
server.registerTool(
|
||||
T.patchResume,
|
||||
{
|
||||
title: "Apply Resume Patch",
|
||||
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.",
|
||||
"",
|
||||
"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 \`${T.unlockResume}\` first.`,
|
||||
].join("\n"),
|
||||
inputSchema: z.object({
|
||||
id: resumeIdSchema,
|
||||
operations: resumePatchOperationsSchema,
|
||||
}),
|
||||
annotations: TOOL_ANNOTATIONS[T.patchResume],
|
||||
},
|
||||
TOOL_META[T.patchResume],
|
||||
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(", ");
|
||||
@@ -400,35 +243,18 @@ export function registerTools(server: McpServer, client: RouterClient<typeof rou
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Update Resume (metadata) ─────────────────────────────────
|
||||
// ── Update Resume (metadata) ─────────────────��───────────────
|
||||
server.registerTool(
|
||||
T.updateResume,
|
||||
{
|
||||
title: "Update Resume (metadata)",
|
||||
description: [
|
||||
"Update resume metadata only: display name, URL slug, tags, and/or public visibility.",
|
||||
"Does not change section content — use JSON Patch via the patch tool for body edits.",
|
||||
`Locked resumes cannot be updated; use \`${T.unlockResume}\` first.`,
|
||||
"Password protection cannot be set or removed via MCP; use the web app for that.",
|
||||
"",
|
||||
"Always returns your canonical share URL (`{app}/{username}/{slug}`). Anonymous viewers can use it only when `isPublic` is true; password protection from the web app still applies.",
|
||||
].join("\n"),
|
||||
inputSchema: z.object({
|
||||
id: resumeIdSchema,
|
||||
name: z.string().min(1).max(64).optional().describe("Display name for the resume."),
|
||||
slug: z.string().min(1).max(64).optional().describe("URL-friendly slug; must stay unique among your resumes."),
|
||||
tags: z.array(z.string()).optional().describe("Replace the resume's tags (omit to leave unchanged)."),
|
||||
isPublic: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"When true, anyone with the link can view the public resume (subject to password if set in the app).",
|
||||
),
|
||||
}),
|
||||
annotations: TOOL_ANNOTATIONS[T.updateResume],
|
||||
},
|
||||
TOOL_META[T.updateResume],
|
||||
withErrorHandling("updating resume", async (params) => {
|
||||
const { id, name, slug, tags, isPublic } = params;
|
||||
const { id, name, slug, tags, isPublic } = params as {
|
||||
id: string;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
tags?: string[];
|
||||
isPublic?: boolean;
|
||||
};
|
||||
if (name === undefined && slug === undefined && tags === undefined && isPublic === undefined)
|
||||
throw new Error("Provide at least one of: name, slug, tags, isPublic.");
|
||||
|
||||
@@ -470,20 +296,10 @@ export function registerTools(server: McpServer, client: RouterClient<typeof rou
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Delete Resume ─────────────────────────────────────────────
|
||||
// ── Delete Resume ────────────────────────────────────────��────
|
||||
server.registerTool(
|
||||
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: z.object({ id: resumeIdSchema }),
|
||||
annotations: TOOL_ANNOTATIONS[T.deleteResume],
|
||||
},
|
||||
TOOL_META[T.deleteResume],
|
||||
withErrorHandling("deleting resume", async ({ id }: { id: string }) => {
|
||||
await client.resume.delete({ id });
|
||||
|
||||
@@ -491,21 +307,10 @@ export function registerTools(server: McpServer, client: RouterClient<typeof rou
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Lock Resume ───────────────────────────────────────────────
|
||||
// ── Lock Resume ────────────────���──────────────────────────────
|
||||
server.registerTool(
|
||||
T.lockResume,
|
||||
{
|
||||
title: "Lock Resume",
|
||||
description: [
|
||||
"Lock a resume to prevent any modifications.",
|
||||
"",
|
||||
`When locked, a resume cannot be edited (${T.patchResume}, ${T.updateResume}), or deleted.`,
|
||||
"Useful for protecting finalized resumes from accidental changes.",
|
||||
`Use \`${T.unlockResume}\` to re-enable editing.`,
|
||||
].join("\n"),
|
||||
inputSchema: z.object({ id: resumeIdSchema }),
|
||||
annotations: TOOL_ANNOTATIONS[T.lockResume],
|
||||
},
|
||||
TOOL_META[T.lockResume],
|
||||
withErrorHandling("locking resume", async ({ id }: { id: string }) => {
|
||||
await client.resume.setLocked({ id, isLocked: true });
|
||||
|
||||
@@ -513,15 +318,10 @@ export function registerTools(server: McpServer, client: RouterClient<typeof rou
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Unlock Resume ─────────────────────────────────────────────
|
||||
// ── Unlock Resume ───────────────���─────────────────────────────
|
||||
server.registerTool(
|
||||
T.unlockResume,
|
||||
{
|
||||
title: "Unlock Resume",
|
||||
description: "Unlock a previously locked resume, re-enabling edits, patches, and deletion.",
|
||||
inputSchema: z.object({ id: resumeIdSchema }),
|
||||
annotations: TOOL_ANNOTATIONS[T.unlockResume],
|
||||
},
|
||||
TOOL_META[T.unlockResume],
|
||||
withErrorHandling("unlocking resume", async ({ id }: { id: string }) => {
|
||||
await client.resume.setLocked({ id, isLocked: false });
|
||||
|
||||
@@ -532,17 +332,7 @@ export function registerTools(server: McpServer, client: RouterClient<typeof rou
|
||||
// ── Get Resume Statistics ────────────────────────────────────
|
||||
server.registerTool(
|
||||
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: z.object({ id: resumeIdSchema }),
|
||||
annotations: TOOL_ANNOTATIONS[T.getResumeStatistics],
|
||||
},
|
||||
TOOL_META[T.getResumeStatistics],
|
||||
withErrorHandling("getting resume statistics", async ({ id }: { id: string }) => {
|
||||
const stats = await client.resume.statistics.getById({ id });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user