diff --git a/packages/mcp/src/mcp-server-card.ts b/packages/mcp/src/mcp-server-card.ts index f16398043..0972bbbea 100644 --- a/packages/mcp/src/mcp-server-card.ts +++ b/packages/mcp/src/mcp-server-card.ts @@ -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 = [ { diff --git a/packages/mcp/src/tool-annotations.ts b/packages/mcp/src/tool-annotations.ts index d8544eb16..5b1edb865 100644 --- a/packages/mcp/src/tool-annotations.ts +++ b/packages/mcp/src/tool-annotations.ts @@ -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 = { - [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, }; diff --git a/packages/mcp/src/tool-meta.ts b/packages/mcp/src/tool-meta.ts new file mode 100644 index 000000000..d1dc6c724 --- /dev/null +++ b/packages/mcp/src/tool-meta.ts @@ -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; diff --git a/packages/mcp/src/tools.ts b/packages/mcp/src/tools.ts index 81eba31a8..2608634d7 100644 --- a/packages/mcp/src/tools.ts +++ b/packages/mcp/src/tools.ts @@ -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[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, 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 { const tags = await client.resume.tags.list(); @@ -152,24 +112,10 @@ export function registerTools(server: McpServer, client: RouterClient { const resume = await client.resume.getById({ id }); @@ -177,19 +123,10 @@ export function registerTools(server: McpServer, client: RouterClient { const analysis = await client.resume.analysis.getById({ id }); @@ -199,20 +136,10 @@ export function registerTools(server: McpServer, client: RouterClient { const resume = await client.resume.getById({ id }); const user = await resolveUserFromRequestHeaders(requestHeaders); @@ -240,31 +167,7 @@ export function registerTools(server: McpServer, client: RouterClient { const parsed = resumeDataSchema.safeParse(data); if (!parsed.success) @@ -326,26 +215,10 @@ export function registerTools(server: McpServer, client: RouterClient { @@ -361,37 +234,7 @@ export function registerTools(server: McpServer, client: RouterClient