From e6a6bf0e6a076ebc1619fae40a821288cec06e7f Mon Sep 17 00:00:00 2001 From: Emanuele Tonello Date: Thu, 17 Sep 2026 06:08:23 +1000 Subject: [PATCH] feat(mcp): add independent cover-letter tools (#3508) Co-authored-by: Amruth Pillai --- docs/guides/using-the-mcp-server.mdx | 19 +++ packages/mcp/src/mcp-server-card.test.ts | 26 ++++ packages/mcp/src/mcp-tool-names.ts | 10 ++ packages/mcp/src/tool-annotations.test.ts | 11 ++ packages/mcp/src/tool-meta.ts | 139 ++++++++++++++++++++++ packages/mcp/src/tools.test.ts | 55 ++++++++- packages/mcp/src/tools.ts | 82 ++++++++++++- 7 files changed, 340 insertions(+), 2 deletions(-) diff --git a/docs/guides/using-the-mcp-server.mdx b/docs/guides/using-the-mcp-server.mdx index 5dad20d46..73273b045 100644 --- a/docs/guides/using-the-mcp-server.mdx +++ b/docs/guides/using-the-mcp-server.mdx @@ -230,6 +230,16 @@ Tool names use canonical unprefixed `snake_case` names. | `lock_resume` | Lock a resume to prevent edits, patches, and deletion | | `unlock_resume` | Unlock a previously locked resume to re-enable editing | | `get_resume_statistics` | Get view and download statistics for a resume | +| `list_cover_letters` | List independent cover letters in the cover-letter library; embedded resume cover letters are not listed here | +| `read_cover_letter` | Read one independent library cover letter by ID | +| `create_cover_letter` | Create an independent library cover letter, optionally linked to a resume or application | +| `update_cover_letter` | Update an independent cover letter with revision-checked concurrency | +| `refresh_cover_letter_style` | Refresh independent-letter styling from a resume without changing its content or template | +| `duplicate_cover_letter` | Create an independent copy of a library cover letter | +| `delete_cover_letter` | Permanently delete an independent library cover letter; requires its current revision | +| `copy_embedded_cover_letter` | Copy a cover-letter item from a resume into the independent cover-letter library; leaves embedded item unchanged | +| `export_cover_letter` | Export one independent library cover letter as versioned cover-letter JSON | +| `import_cover_letter` | Import versioned cover-letter JSON as a new independent library letter | | `list_applications` | List tracked job applications. Supports stage, tag, and archived filters | | `read_application` | Read one full application record with contacts, follow-up details, documents, and timeline | | `list_application_tags` | List every distinct tag used across applications | @@ -252,6 +262,15 @@ Tool names use canonical unprefixed `snake_case` names. Older clients may refer to prefixed or dot-separated names. Those names are no longer registered; update automations and saved prompts to the canonical names above. +### Independent and embedded cover letters + +Reactive Resume has two cover-letter scopes: + +- **Independent library letters** live in the **Cover Letters** dashboard and have their own IDs, revisions, templates, exports, and lifecycle. Use `list_cover_letters`, `read_cover_letter`, `create_cover_letter`, `update_cover_letter`, `refresh_cover_letter_style`, `duplicate_cover_letter`, `delete_cover_letter`, `export_cover_letter`, and `import_cover_letter` for this scope. +- **Embedded letters** live as cover-letter items inside a resume's custom sections. They are part of that resume's `ResumeData`; use `read_resume` and `apply_resume_patch` to inspect or edit them. Use `copy_embedded_cover_letter` when you want to create a separate independent library copy. Copying does not remove or change the embedded item. + +Updating or deleting an independent letter requires its latest `revision` as `expectedRevision`. This prevents concurrent MCP clients from overwriting newer edits. + ## Available resources 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. diff --git a/packages/mcp/src/mcp-server-card.test.ts b/packages/mcp/src/mcp-server-card.test.ts index 1c9def5fa..a4f2a866b 100644 --- a/packages/mcp/src/mcp-server-card.test.ts +++ b/packages/mcp/src/mcp-server-card.test.ts @@ -53,6 +53,24 @@ describe("buildMcpServerCard", () => { expect(names).toContain("tailor_resume_for_application"); }); + it("advertises independent cover-letter library tools", () => { + const names = card.tools.map((tool) => tool.name); + expect(names).toEqual( + expect.arrayContaining([ + "list_cover_letters", + "read_cover_letter", + "create_cover_letter", + "update_cover_letter", + "refresh_cover_letter_style", + "duplicate_cover_letter", + "delete_cover_letter", + "copy_embedded_cover_letter", + "export_cover_letter", + "import_cover_letter", + ]), + ); + }); + it("declares a JSON Schema input for every tool", () => { for (const tool of card.tools) { expect(tool.inputSchema, tool.name).toBeDefined(); @@ -92,6 +110,14 @@ describe("buildMcpServerCard", () => { expect(update.safeParse({ id: "app-1", archived: true }).success).toBe(true); }); + it.each([{ content: "Updated" }, { recipient: "Dear Hiring Manager" }, { template: "onyx" }])( + "accepts a partial cover-letter update without a name: %j", + (fields) => { + const input = { id: "letter-1", expectedRevision: 1, ...fields }; + expect(TOOL_META[MCP_TOOL_NAME.updateCoverLetter].inputSchema.parse(input)).toEqual(input); + }, + ); + it("accepts only http/https application source URLs", () => { const create = TOOL_META[MCP_TOOL_NAME.createApplication].inputSchema; diff --git a/packages/mcp/src/mcp-tool-names.ts b/packages/mcp/src/mcp-tool-names.ts index 83811c495..5029664ac 100644 --- a/packages/mcp/src/mcp-tool-names.ts +++ b/packages/mcp/src/mcp-tool-names.ts @@ -13,6 +13,16 @@ export const MCP_TOOL_NAME = { lockResume: "lock_resume", unlockResume: "unlock_resume", getResumeStatistics: "get_resume_statistics", + listCoverLetters: "list_cover_letters", + readCoverLetter: "read_cover_letter", + createCoverLetter: "create_cover_letter", + updateCoverLetter: "update_cover_letter", + refreshCoverLetterStyle: "refresh_cover_letter_style", + duplicateCoverLetter: "duplicate_cover_letter", + deleteCoverLetter: "delete_cover_letter", + copyEmbeddedCoverLetter: "copy_embedded_cover_letter", + exportCoverLetter: "export_cover_letter", + importCoverLetter: "import_cover_letter", listApplications: "list_applications", readApplication: "read_application", listApplicationTags: "list_application_tags", diff --git a/packages/mcp/src/tool-annotations.test.ts b/packages/mcp/src/tool-annotations.test.ts index 1847d9151..6feef77b3 100644 --- a/packages/mcp/src/tool-annotations.test.ts +++ b/packages/mcp/src/tool-annotations.test.ts @@ -49,6 +49,9 @@ describe("tool annotations", () => { MCP_TOOL_NAME.readApplication, MCP_TOOL_NAME.listApplicationTags, MCP_TOOL_NAME.getApplicationStats, + MCP_TOOL_NAME.listCoverLetters, + MCP_TOOL_NAME.readCoverLetter, + MCP_TOOL_NAME.exportCoverLetter, ]; for (const name of readOnlyTools) { const annotations = TOOL_META[name].annotations; @@ -88,6 +91,10 @@ describe("tool annotations", () => { MCP_TOOL_NAME.createApplication, MCP_TOOL_NAME.importApplications, MCP_TOOL_NAME.draftApplicationMessage, + MCP_TOOL_NAME.createCoverLetter, + MCP_TOOL_NAME.duplicateCoverLetter, + MCP_TOOL_NAME.copyEmbeddedCoverLetter, + MCP_TOOL_NAME.importCoverLetter, ]) { const annotations = TOOL_META[name].annotations; expect(annotations.readOnlyHint, name).toBe(false); @@ -116,6 +123,9 @@ describe("tool annotations", () => { MCP_TOOL_NAME.removeApplicationDocument, MCP_TOOL_NAME.scoreApplicationMatch, MCP_TOOL_NAME.tailorResumeForApplication, + MCP_TOOL_NAME.updateCoverLetter, + MCP_TOOL_NAME.refreshCoverLetterStyle, + MCP_TOOL_NAME.deleteCoverLetter, ]) { expect(TOOL_META[name].annotations.readOnlyHint, name).toBe(false); expect(TOOL_META[name].annotations.destructiveHint, name).toBe(true); @@ -131,6 +141,7 @@ describe("tool annotations", () => { MCP_TOOL_NAME.removeApplicationDocument, MCP_TOOL_NAME.deleteApplication, MCP_TOOL_NAME.bulkDeleteApplications, + MCP_TOOL_NAME.deleteCoverLetter, MCP_TOOL_NAME.autofillApplicationFromJob, MCP_TOOL_NAME.scoreApplicationMatch, MCP_TOOL_NAME.tailorResumeForApplication, diff --git a/packages/mcp/src/tool-meta.ts b/packages/mcp/src/tool-meta.ts index 831a8aefd..fe31e7766 100644 --- a/packages/mcp/src/tool-meta.ts +++ b/packages/mcp/src/tool-meta.ts @@ -6,6 +6,8 @@ import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; import z from "zod"; import { resumePatchOperationsSchema } from "@reactive-resume/ai/tools/resume-tool-contracts"; import { applicationStatusSchema, contactSchema } from "@reactive-resume/schema/applications/data"; +import { coverLetterDocumentSchema } from "@reactive-resume/schema/cover-letter/data"; +import { templateSchema } from "@reactive-resume/schema/templates"; import { MCP_TOOL_NAME as T } from "./mcp-tool-names"; const MAX_APPLICATION_DOCUMENT_BYTES = 10 * 1024 * 1024; @@ -48,6 +50,20 @@ const applicationIdSchema = z .describe(`Application ID. Use \`${T.listApplications}\` to find valid IDs.`); const applicationTimelineEntryIdSchema = z.string().min(1).describe("Timeline entry ID from an application response."); const applicationDocumentKindSchema = z.enum(["resume", "cover-letter"]); +const coverLetterIdSchema = z + .string() + .min(1) + .describe(`Cover letter ID. Use \`${T.listCoverLetters}\` to find valid IDs.`); +const expectedRevisionSchema = z + .number() + .int() + .min(1) + .describe("Revision returned by the latest cover-letter response."); +const coverLetterEditableFieldsSchema = { + name: z.string().min(1).max(100).describe("Cover-letter name."), + recipient: z.string().max(20_000).optional().describe("Recipient and salutation HTML."), + content: z.string().max(100_000).optional().describe("Cover-letter body HTML."), +}; const timelineDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must use YYYY-MM-DD format."); const httpUrlSchema = z .string() @@ -319,6 +335,129 @@ export const TOOL_META = { inputSchema: z.object({ id: resumeIdSchema }), annotations: READ_IDEMPOTENT, }, + [T.listCoverLetters]: { + title: "List Cover Letters", + description: [ + "List independent cover letters in the account's cover-letter library.", + "These are separate from cover-letter sections embedded in resumes.", + "Use this before other independent cover-letter tools to discover IDs.", + ].join("\n"), + inputSchema: z.object({ + search: z.string().max(100).optional().describe("Filter by cover-letter name."), + resumeId: z.string().min(1).optional().describe("Filter by source resume ID."), + applicationId: z.string().min(1).optional().describe("Filter by source application ID."), + limit: z.number().int().min(1).max(100).optional().default(20).describe("Maximum results. Default: 20."), + offset: z.number().int().min(0).optional().default(0).describe("Number of results to skip. Default: 0."), + }), + annotations: READ_IDEMPOTENT, + }, + [T.readCoverLetter]: { + title: "Read Cover Letter", + description: [ + "Read one independent cover letter from the cover-letter library.", + "This does not read a cover-letter section embedded in a resume.", + `Use \`${T.listCoverLetters}\` first to find valid IDs.`, + ].join("\n"), + inputSchema: z.object({ id: coverLetterIdSchema }), + annotations: READ_IDEMPOTENT, + }, + [T.createCoverLetter]: { + title: "Create Cover Letter", + description: [ + "Create an independent cover letter in the cover-letter library.", + "Optionally associate it with a resume or application; this does not add an embedded section to a resume.", + ].join("\n"), + inputSchema: z.object({ + ...coverLetterEditableFieldsSchema, + recipient: z.string().max(20_000).optional().default(""), + content: z.string().max(100_000).optional().default(""), + resumeId: z.string().min(1).optional().describe("Optional source resume ID."), + applicationId: z.string().min(1).optional().describe("Optional source application ID."), + template: templateSchema.optional().describe("Optional template for this cover letter."), + }), + annotations: WRITE_NON_IDEMPOTENT, + }, + [T.updateCoverLetter]: { + title: "Update Cover Letter", + description: [ + "Update an independent cover letter's name, recipient, content, or template.", + "Pass the latest `revision` as `expectedRevision`; stale writes are rejected instead of overwriting newer edits.", + ].join("\n"), + inputSchema: z.object({ + id: coverLetterIdSchema, + expectedRevision: expectedRevisionSchema, + ...coverLetterEditableFieldsSchema, + name: coverLetterEditableFieldsSchema.name.optional(), + template: templateSchema.optional().describe("Replacement template. Omit to keep the current template."), + }), + annotations: { ...WRITE_NON_IDEMPOTENT, destructiveHint: true }, + }, + [T.refreshCoverLetterStyle]: { + title: "Refresh Cover Letter Style", + description: [ + "Refresh an independent cover letter's sender styling from a resume while preserving its content and template.", + "Pass the latest `revision` as `expectedRevision` to prevent stale concurrent writes.", + "This updates the independent letter; it does not modify the embedded cover letter in the resume.", + ].join("\n"), + inputSchema: z.object({ + id: coverLetterIdSchema, + expectedRevision: expectedRevisionSchema, + resumeId: z.string().min(1).describe("Resume ID to copy sender styling from."), + }), + annotations: { ...WRITE_NON_IDEMPOTENT, destructiveHint: true }, + }, + [T.duplicateCoverLetter]: { + title: "Duplicate Cover Letter", + description: [ + "Create an independent copy of a cover letter in the library.", + "The copy is separate from the original and from any embedded resume cover letter.", + ].join("\n"), + inputSchema: z.object({ id: coverLetterIdSchema, name: z.string().min(1).max(100).optional() }), + annotations: WRITE_NON_IDEMPOTENT, + }, + [T.deleteCoverLetter]: { + title: "Delete Cover Letter", + description: [ + "Permanently delete an independent cover letter from the library.", + "Pass the latest `revision` as `expectedRevision`; this does not delete embedded cover-letter sections.", + ].join("\n"), + inputSchema: z.object({ id: coverLetterIdSchema, expectedRevision: expectedRevisionSchema }), + annotations: { ...WRITE_DESTRUCTIVE, openWorldHint: true }, + }, + [T.copyEmbeddedCoverLetter]: { + title: "Copy Embedded Cover Letter", + description: [ + "Copy a cover-letter item embedded in a resume into the independent cover-letter library.", + "The embedded item remains in the resume; the returned letter is a new independent library record.", + ].join("\n"), + inputSchema: z.object({ + resumeId: z.string().min(1).describe("Resume containing the embedded cover letter."), + sectionId: z.string().min(1).describe("Embedded cover-letter section ID."), + itemId: z.string().min(1).describe("Embedded cover-letter item ID."), + name: z.string().min(1).max(100).optional().describe("Optional name for the independent copy."), + }), + annotations: WRITE_NON_IDEMPOTENT, + }, + [T.exportCoverLetter]: { + title: "Export Cover Letter", + description: [ + "Export an independent library cover letter as versioned Reactive Resume cover-letter JSON.", + "This is not a full resume export and does not export an embedded cover letter directly.", + ].join("\n"), + inputSchema: z.object({ id: coverLetterIdSchema }), + annotations: READ_IDEMPOTENT, + }, + [T.importCoverLetter]: { + title: "Import Cover Letter", + description: [ + "Import a versioned Reactive Resume cover-letter JSON document as a new independent library letter.", + "Use `export_cover_letter` to obtain the accepted document format.", + ].join("\n"), + inputSchema: z.object({ + document: coverLetterDocumentSchema.describe("Versioned independent cover-letter JSON document."), + }), + annotations: WRITE_NON_IDEMPOTENT, + }, [T.listApplications]: { title: "List Applications", description: diff --git a/packages/mcp/src/tools.test.ts b/packages/mcp/src/tools.test.ts index a26b722cd..4e2635769 100644 --- a/packages/mcp/src/tools.test.ts +++ b/packages/mcp/src/tools.test.ts @@ -65,6 +65,18 @@ const clientMock = { setLocked: vi.fn(), statistics: { getById: vi.fn() }, }, + coverLetters: { + list: vi.fn(), + getById: vi.fn(), + create: vi.fn(), + update: vi.fn(), + refreshStyle: vi.fn(), + duplicate: vi.fn(), + delete: vi.fn(), + copyEmbedded: vi.fn(), + export: vi.fn(), + import: vi.fn(), + }, applications: { list: vi.fn(), getById: vi.fn(), @@ -201,6 +213,47 @@ describe("registerTools", () => { expect(names).toContain("draft_application_message"); }); + it("registers and routes independent cover-letter tools", async () => { + clientMock.coverLetters.list.mockResolvedValueOnce({ items: [{ id: "letter-1" }], total: 1 }); + clientMock.coverLetters.update.mockResolvedValueOnce({ id: "letter-1", revision: 2 }); + clientMock.coverLetters.delete.mockResolvedValueOnce(undefined); + const { server, registered } = makeFakeServer(); + registerTools(server as never, clientMock as never, new Headers()); + + expect(registered.map((item) => item.name)).toEqual( + expect.arrayContaining([ + MCP_TOOL_NAME.listCoverLetters, + MCP_TOOL_NAME.readCoverLetter, + MCP_TOOL_NAME.createCoverLetter, + MCP_TOOL_NAME.updateCoverLetter, + MCP_TOOL_NAME.refreshCoverLetterStyle, + MCP_TOOL_NAME.duplicateCoverLetter, + MCP_TOOL_NAME.deleteCoverLetter, + MCP_TOOL_NAME.copyEmbeddedCoverLetter, + MCP_TOOL_NAME.exportCoverLetter, + MCP_TOOL_NAME.importCoverLetter, + ]), + ); + + const list = registered.find((item) => item.name === MCP_TOOL_NAME.listCoverLetters)!; + const listResult = await list.handler({ search: "Acme", limit: 10, offset: 0 }); + expect(clientMock.coverLetters.list).toHaveBeenCalledWith({ search: "Acme", limit: 10, offset: 0 }); + expect(JSON.parse(listResult.content[0]!.text)).toEqual({ items: [{ id: "letter-1" }], total: 1 }); + + const update = registered.find((item) => item.name === MCP_TOOL_NAME.updateCoverLetter)!; + await update.handler({ id: "letter-1", expectedRevision: 1, content: "Updated" }); + expect(clientMock.coverLetters.update).toHaveBeenCalledWith({ + id: "letter-1", + expectedRevision: 1, + content: "Updated", + }); + + const remove = registered.find((item) => item.name === MCP_TOOL_NAME.deleteCoverLetter)!; + const deleteResult = await remove.handler({ id: "letter-1", expectedRevision: 2 }); + expect(clientMock.coverLetters.delete).toHaveBeenCalledWith({ id: "letter-1", expectedRevision: 2 }); + expect(deleteResult.content[0]!.text).toContain("Deleted cover letter (letter-1)."); + }); + it("lists applications as JSON", async () => { clientMock.applications.list.mockResolvedValueOnce([{ id: "app-1", company: "Acme", role: "Engineer" }]); const { server, registered } = makeFakeServer(); @@ -372,7 +425,7 @@ describe("registerTools", () => { [ "NOT_FOUND", undefined, - `\`${MCP_TOOL_NAME.listResumes}\` and \`${MCP_TOOL_NAME.listApplications}\` return valid ones.`, + `\`${MCP_TOOL_NAME.listResumes}\`, \`${MCP_TOOL_NAME.listCoverLetters}\`, and \`${MCP_TOOL_NAME.listApplications}\` return valid ones.`, ], ["RESUME_SLUG_ALREADY_EXISTS", 400, "The slug is already in use."], ["FORBIDDEN", undefined, "Permission denied."], diff --git a/packages/mcp/src/tools.ts b/packages/mcp/src/tools.ts index a67150683..f19e04400 100644 --- a/packages/mcp/src/tools.ts +++ b/packages/mcp/src/tools.ts @@ -42,7 +42,7 @@ function errorHint(error: unknown): string { // Every tool shares this handler, so the wording stays entity-agnostic: `NOT_FOUND` is // thrown by the application procedures too, and resume-flavoured advice misdirects there. if (code === "NOT_FOUND" || status === 404) - return `\n\nHint: Not found. Check the ID — \`${listResumes}\` and \`${listApplications}\` return valid ones.`; + return `\n\nHint: Not found. Check the ID — \`${listResumes}\`, \`${MCP_TOOL_NAME.listCoverLetters}\`, and \`${listApplications}\` return valid ones.`; if (code === "FORBIDDEN" || status === 403) return "\n\nHint: Permission denied. This account cannot access that record."; if (status === 400) return "\n\nHint: Invalid request. Check the input parameters against the tool's schema."; @@ -371,6 +371,86 @@ export function registerTools(server: McpServer, client: RouterClient json(await client.coverLetters.list(params as never))), + ); + + server.registerTool( + T.readCoverLetter, + TOOL_META[T.readCoverLetter], + withErrorHandling("reading cover letter", async ({ id }: { id: string }) => + json(await client.coverLetters.getById({ id })), + ), + ); + + server.registerTool( + T.createCoverLetter, + TOOL_META[T.createCoverLetter], + withErrorHandling("creating cover letter", async (params) => + json(await client.coverLetters.create(params as never)), + ), + ); + + server.registerTool( + T.updateCoverLetter, + TOOL_META[T.updateCoverLetter], + withErrorHandling("updating cover letter", async (params) => + json(await client.coverLetters.update(params as never)), + ), + ); + + server.registerTool( + T.refreshCoverLetterStyle, + TOOL_META[T.refreshCoverLetterStyle], + withErrorHandling("refreshing cover letter style", async (params) => + json(await client.coverLetters.refreshStyle(params as never)), + ), + ); + + server.registerTool( + T.duplicateCoverLetter, + TOOL_META[T.duplicateCoverLetter], + withErrorHandling("duplicating cover letter", async (params) => + json(await client.coverLetters.duplicate(params as never)), + ), + ); + + server.registerTool( + T.deleteCoverLetter, + TOOL_META[T.deleteCoverLetter], + withErrorHandling("deleting cover letter", async (params) => { + await client.coverLetters.delete(params as never); + return text(`Deleted cover letter (${(params as { id: string }).id}).`); + }), + ); + + server.registerTool( + T.copyEmbeddedCoverLetter, + TOOL_META[T.copyEmbeddedCoverLetter], + withErrorHandling("copying embedded cover letter", async (params) => + json(await client.coverLetters.copyEmbedded(params as never)), + ), + ); + + server.registerTool( + T.exportCoverLetter, + TOOL_META[T.exportCoverLetter], + withErrorHandling("exporting cover letter", async ({ id }: { id: string }) => + json(await client.coverLetters.export({ id })), + ), + ); + + server.registerTool( + T.importCoverLetter, + TOOL_META[T.importCoverLetter], + withErrorHandling("importing cover letter", async ({ document }: { document: unknown }) => + json(await client.coverLetters.import({ document } as never)), + ), + ); + // ── Applications ────────────────────────────────────────────── server.registerTool( T.listApplications,