feat(mcp): add independent cover-letter tools (#3508)

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
This commit is contained in:
Emanuele Tonello
2026-09-16 22:08:23 +02:00
committed by GitHub
co-authored by Amruth Pillai
parent 96c7142fbc
commit e6a6bf0e6a
7 changed files with 340 additions and 2 deletions
+19
View File
@@ -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.
+26
View File
@@ -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;
+10
View File
@@ -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",
+11
View File
@@ -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,
+139
View File
@@ -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:
+54 -1
View File
@@ -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."],
+81 -1
View File
@@ -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<typeof rou
}),
);
// ── Independent Cover Letters ───────────────────────────────────
server.registerTool(
T.listCoverLetters,
TOOL_META[T.listCoverLetters],
withErrorHandling("listing cover letters", async (params) => 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,