Add application tracker REST and MCP parity

Add comprehensive Application Tracker REST and MCP coverage, document the MCP workflow, add Markdown/ActionLint checks, bump the release version, and fill all extracted translations.
This commit is contained in:
Amruth Pillai
2026-07-07 17:02:39 +02:00
committed by GitHub
parent dfc5559625
commit 46afc65cc6
80 changed files with 9938 additions and 809 deletions
+49
View File
@@ -34,3 +34,52 @@ describe("applicationDto jobDescription", () => {
).toThrow();
});
});
describe("applicationDto document uploads", () => {
it("accepts PDF application documents", () => {
const file = new File(["%PDF-1.4"], "resume.pdf", { type: "application/pdf" });
const parsed = applicationDto.attachDocument.input.parse({
id: "application-1",
kind: "resume",
file,
});
expect(parsed.kind).toBe("resume");
expect(parsed.file.name).toBe("resume.pdf");
});
it("rejects non-PDF application documents", () => {
const file = new File(["hello"], "cover.txt", { type: "text/plain" });
expect(() =>
applicationDto.attachDocument.input.parse({
id: "application-1",
kind: "cover-letter",
file,
}),
).toThrow();
});
it("rejects unknown application document kinds", () => {
const file = new File(["%PDF-1.4"], "resume.pdf", { type: "application/pdf" });
expect(() =>
applicationDto.attachDocument.input.parse({
id: "application-1",
kind: "portfolio",
file,
}),
).toThrow();
});
});
describe("applicationDto zero-argument inputs", () => {
it("normalizes stats input to an empty object", () => {
expect(applicationDto.stats.input.parse(undefined)).toEqual({});
});
it("normalizes tags input to an empty object", () => {
expect(applicationDto.tags.input.parse(undefined)).toEqual({});
});
});
+29 -2
View File
@@ -9,6 +9,14 @@ import {
} from "@reactive-resume/schema/applications/data";
const MAX_APPLICATION_JOB_DESCRIPTION_CHARS = 20_000;
const MAX_APPLICATION_DOCUMENT_BYTES = 10 * 1024 * 1024;
const applicationDocumentKindSchema = z.enum(["resume", "cover-letter"]);
const applicationDocumentFileSchema = z
.file()
.max(MAX_APPLICATION_DOCUMENT_BYTES, "File size must be less than 10MB")
.mime(["application/pdf"], "Application documents must be PDF files.");
const httpUrlSchema = z
.string()
@@ -124,6 +132,23 @@ export const applicationDto = {
output: applicationSchema.omit({ userId: true }),
},
attachDocument: {
input: z.object({
id: z.string(),
kind: applicationDocumentKindSchema,
file: applicationDocumentFileSchema,
}),
output: applicationSchema.omit({ userId: true }),
},
removeDocument: {
input: z.object({
id: z.string(),
kind: applicationDocumentKindSchema,
}),
output: applicationSchema.omit({ userId: true }),
},
addNote: {
input: z.object({ id: z.string(), text: z.string().trim().min(1) }),
output: applicationSchema.omit({ userId: true }),
@@ -153,7 +178,7 @@ export const applicationDto = {
// Aggregates for the Insights view. Everything else (funnel, sankey, tiles) is derived
// client-side from these raw counts via computeInsights().
stats: {
input: z.void(),
input: z.object({}).optional().default({}),
output: z.object({
total: z.number(),
byStage: z.array(z.object({ status: applicationStatusSchema, count: z.number() })),
@@ -162,7 +187,9 @@ export const applicationDto = {
},
tags: {
input: z.void(),
input: z.object({}).optional().default({}),
output: z.array(z.string()),
},
};
export type ApplicationDocumentKind = z.infer<typeof applicationDocumentKindSchema>;
@@ -97,6 +97,66 @@ export const crudRouter = {
return applicationService.update({ userId: context.user.id, ...input });
}),
attachDocument: protectedProcedure
.route({
method: "POST",
path: "/applications/{id}/documents/{kind}",
tags: ["Applications"],
operationId: "attachApplicationDocument",
summary: "Attach an application document",
description:
"Uploads and attaches a PDF document to an application. Kind must be either resume or cover-letter. Requires authentication.",
successDescription: "The updated application.",
spec: (current) => {
const requestBody = current.requestBody;
if (!requestBody || "$ref" in requestBody) return current;
const multipart = requestBody.content?.["multipart/form-data"];
if (!multipart) return current;
return {
...current,
requestBody: {
...requestBody,
content: { "multipart/form-data": multipart },
},
};
},
})
.input(applicationDto.attachDocument.input)
.use(resumeMutationRateLimit)
.output(applicationDto.attachDocument.output)
.handler(async ({ input, context }) => {
const buffer = await input.file.arrayBuffer();
return applicationService.attachDocument({
id: input.id,
userId: context.user.id,
kind: input.kind,
fileName: input.file.name,
contentType: input.file.type,
data: new Uint8Array(buffer),
});
}),
removeDocument: protectedProcedure
.route({
method: "DELETE",
path: "/applications/{id}/documents/{kind}",
tags: ["Applications"],
operationId: "removeApplicationDocument",
summary: "Remove an application document",
description:
"Removes a resume or cover-letter PDF from an application and clears the stored document fields. Requires authentication.",
successDescription: "The updated application.",
})
.input(applicationDto.removeDocument.input)
.use(resumeMutationRateLimit)
.output(applicationDto.removeDocument.output)
.handler(async ({ input, context }) => {
return applicationService.removeDocument({ id: input.id, userId: context.user.id, kind: input.kind });
}),
addNote: protectedProcedure
.route({
method: "POST",
@@ -7,6 +7,8 @@ export const applicationsRouter = {
create: crudRouter.create,
import: crudRouter.import,
update: crudRouter.update,
attachDocument: crudRouter.attachDocument,
removeDocument: crudRouter.removeDocument,
addNote: crudRouter.addNote,
delete: crudRouter.delete,
bulkUpdate: crudRouter.bulkUpdate,
@@ -9,6 +9,7 @@ const dbMock = vi.hoisted(() => ({
}));
const resumeGetByIdMock = vi.hoisted(() => vi.fn());
const storageDeleteMock = vi.hoisted(() => vi.fn());
const uploadFileMock = vi.hoisted(() => vi.fn());
vi.mock("@reactive-resume/db/client", () => ({ db: dbMock }));
vi.mock("@reactive-resume/db/schema", () => ({
@@ -36,6 +37,7 @@ vi.mock("../resume/service", () => ({
}));
vi.mock("../storage/service", () => ({
getStorageService: () => ({ delete: storageDeleteMock }),
uploadFile: uploadFileMock,
}));
const { applicationService } = await import("./service");
@@ -51,17 +53,34 @@ const existing = {
coverLetterUrl: "/api/uploads/user-1/pictures/cover.pdf",
};
beforeEach(() => {
const createSelectChain = (rows: unknown[]) => ({
from: () => ({
where: () => Promise.resolve(rows),
}),
});
const setSelectResults = (...results: unknown[][]) => {
dbMock.select.mockReset();
for (const rows of results) {
dbMock.select.mockReturnValueOnce(createSelectChain(rows));
}
dbMock.select.mockReturnValue(createSelectChain([]));
};
beforeEach(() => {
dbMock.insert.mockReset();
dbMock.update.mockReset();
dbMock.delete.mockReset();
resumeGetByIdMock.mockReset();
storageDeleteMock.mockReset();
uploadFileMock.mockReset();
resumeGetByIdMock.mockResolvedValue({ id: "resume-1" });
storageDeleteMock.mockResolvedValue(true);
// requireOwned: db.select().from().where() resolves to [existing]
dbMock.select.mockReturnValue({ from: () => ({ where: () => Promise.resolve([{ ...existing }]) }) });
uploadFileMock.mockResolvedValue({
url: "/api/uploads/user-1/pictures/new.pdf",
key: "uploads/user-1/pictures/new.pdf",
});
setSelectResults([{ ...existing }]);
});
describe("applicationService.create", () => {
@@ -136,22 +155,129 @@ describe("applicationService.delete", () => {
});
});
describe("applicationService.attachDocument", () => {
it("uploads a PDF resume document and stores it on the application", async () => {
setSelectResults([{ ...existing }], [{ ...existing }], []);
const set = vi.fn(() => ({ where: () => ({ returning: () => Promise.resolve([{ ...existing }]) }) }));
dbMock.update.mockReturnValue({ set });
await applicationService.attachDocument({
id: "app-1",
userId: "user-1",
kind: "resume",
fileName: "sent-resume.pdf",
contentType: "application/pdf",
data: new Uint8Array([1, 2, 3]),
});
expect(uploadFileMock).toHaveBeenCalledWith({
userId: "user-1",
contentType: "application/pdf",
data: new Uint8Array([1, 2, 3]),
});
expect(set).toHaveBeenCalledWith(
expect.objectContaining({
resumeFileUrl: "/api/uploads/user-1/pictures/new.pdf",
resumeFileName: "sent-resume.pdf",
}),
);
});
it("rejects non-PDF documents before upload", async () => {
await expect(
applicationService.attachDocument({
id: "app-1",
userId: "user-1",
kind: "cover-letter",
fileName: "cover.txt",
contentType: "text/plain",
data: new Uint8Array([1]),
}),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(uploadFileMock).not.toHaveBeenCalled();
});
it("does not delete the replaced upload when another application still references it", async () => {
setSelectResults(
[{ ...existing }],
[{ ...existing }],
[
{
id: "app-2",
resumeFileUrl: existing.resumeFileUrl,
coverLetterUrl: null,
},
],
);
const set = vi.fn(() => ({ where: () => ({ returning: () => Promise.resolve([{ ...existing }]) }) }));
dbMock.update.mockReturnValue({ set });
await applicationService.attachDocument({
id: "app-1",
userId: "user-1",
kind: "resume",
fileName: "sent-resume.pdf",
contentType: "application/pdf",
data: new Uint8Array([1, 2, 3]),
});
expect(storageDeleteMock).not.toHaveBeenCalledWith("uploads/user-1/pictures/resume.pdf");
});
});
describe("applicationService.removeDocument", () => {
it("clears and deletes an owned cover letter document", async () => {
setSelectResults([{ ...existing }], [{ ...existing }], []);
const set = vi.fn(() => ({ where: () => ({ returning: () => Promise.resolve([{ ...existing }]) }) }));
dbMock.update.mockReturnValue({ set });
await applicationService.removeDocument({ id: "app-1", userId: "user-1", kind: "cover-letter" });
expect(set).toHaveBeenCalledWith(
expect.objectContaining({
coverLetterUrl: null,
coverLetterName: null,
}),
);
expect(storageDeleteMock).toHaveBeenCalledWith("uploads/user-1/pictures/cover.pdf");
});
it("does not delete a removed upload while another application still references it", async () => {
setSelectResults(
[{ ...existing }],
[{ ...existing }],
[
{
id: "app-2",
resumeFileUrl: null,
coverLetterUrl: existing.coverLetterUrl,
},
],
);
const set = vi.fn(() => ({ where: () => ({ returning: () => Promise.resolve([{ ...existing }]) }) }));
dbMock.update.mockReturnValue({ set });
await applicationService.removeDocument({ id: "app-1", userId: "user-1", kind: "cover-letter" });
expect(storageDeleteMock).not.toHaveBeenCalledWith("uploads/user-1/pictures/cover.pdf");
});
});
describe("applicationService.bulkDelete", () => {
it("deletes uploaded attachments for deleted owned applications", async () => {
dbMock.select.mockReturnValue({
from: () => ({
where: () =>
Promise.resolve([
{ ...existing, id: "app-1" },
{
...existing,
id: "app-2",
resumeFileUrl: "http://localhost:3000/api/uploads/user-2/pictures/ignored.pdf",
coverLetterUrl: null,
},
]),
}),
});
setSelectResults(
[
{ ...existing, id: "app-1" },
{
...existing,
id: "app-2",
resumeFileUrl: "http://localhost:3000/api/uploads/user-2/pictures/ignored.pdf",
coverLetterUrl: null,
},
],
[],
);
dbMock.delete.mockReturnValue({
where: () => ({ returning: () => Promise.resolve([{ id: "app-1" }, { id: "app-2" }]) }),
});
@@ -1,4 +1,5 @@
import type { ActivityEvent, AiMetadata, ApplicationStatus, Contact } from "@reactive-resume/schema/applications/data";
import type { ApplicationDocumentKind } from "../../dto/application";
import { ORPCError } from "@orpc/client";
import { and, arrayContains, desc, eq, inArray, sql } from "drizzle-orm";
import { db } from "@reactive-resume/db/client";
@@ -6,7 +7,7 @@ import * as schema from "@reactive-resume/db/schema";
import { STAGES } from "@reactive-resume/schema/applications/data";
import { generateId } from "@reactive-resume/utils/string";
import { resumeService } from "../resume/service";
import { getStorageService } from "../storage/service";
import { getStorageService, uploadFile } from "../storage/service";
const stageLabel = (status: ApplicationStatus) => STAGES.find((s) => s.value === status)?.label ?? status;
@@ -79,7 +80,7 @@ async function deleteApplicationAttachments(
userId: string,
applications: { resumeFileUrl?: string | null; coverLetterUrl?: string | null }[],
) {
const keys = [
const candidateKeys = [
...new Set(
applications.flatMap((application) => [
storageKeyFromApplicationUrl(userId, application.resumeFileUrl),
@@ -88,11 +89,41 @@ async function deleteApplicationAttachments(
),
].filter((key): key is string => !!key);
if (candidateKeys.length === 0) return;
const remainingApplications = await db
.select({
resumeFileUrl: schema.application.resumeFileUrl,
coverLetterUrl: schema.application.coverLetterUrl,
})
.from(schema.application)
.where(eq(schema.application.userId, userId));
const referencedKeys = new Set(
remainingApplications.flatMap((application) => [
storageKeyFromApplicationUrl(userId, application.resumeFileUrl),
storageKeyFromApplicationUrl(userId, application.coverLetterUrl),
]),
);
const keys = candidateKeys.filter((key) => !referencedKeys.has(key));
if (keys.length === 0) return;
const storageService = getStorageService();
await Promise.allSettled(keys.map((key) => storageService.delete(key)));
}
function documentFields(kind: ApplicationDocumentKind) {
return kind === "resume"
? ({
url: "resumeFileUrl",
name: "resumeFileName",
} as const)
: ({
url: "coverLetterUrl",
name: "coverLetterName",
} as const);
}
const stripUserId = <T extends { userId: string }>(row: T) => {
const { userId: _userId, ...rest } = row;
return rest;
@@ -197,6 +228,70 @@ export const applicationService = {
return stripUserId(updated);
},
attachDocument: async (input: {
id: string;
userId: string;
kind: ApplicationDocumentKind;
fileName: string;
data: Uint8Array;
contentType: string;
}) => {
if (input.contentType !== "application/pdf") {
throw new ORPCError("BAD_REQUEST", { message: "Application documents must be PDF files." });
}
const existing = await requireOwned(input.id, input.userId);
const fields = documentFields(input.kind);
const uploaded = await uploadFile({
userId: input.userId,
data: input.data,
contentType: input.contentType,
});
try {
const updated = await applicationService.update({
id: input.id,
userId: input.userId,
[fields.url]: uploaded.url,
[fields.name]: input.fileName,
});
await deleteApplicationAttachments(input.userId, [
{
resumeFileUrl: fields.url === "resumeFileUrl" ? existing.resumeFileUrl : null,
coverLetterUrl: fields.url === "coverLetterUrl" ? existing.coverLetterUrl : null,
},
]);
return updated;
} catch (error) {
await getStorageService()
.delete(uploaded.key)
.catch(() => false);
throw error;
}
},
removeDocument: async (input: { id: string; userId: string; kind: ApplicationDocumentKind }) => {
const existing = await requireOwned(input.id, input.userId);
const fields = documentFields(input.kind);
const updated = await applicationService.update({
id: input.id,
userId: input.userId,
[fields.url]: null,
[fields.name]: null,
});
await deleteApplicationAttachments(input.userId, [
{
resumeFileUrl: fields.url === "resumeFileUrl" ? existing.resumeFileUrl : null,
coverLetterUrl: fields.url === "coverLetterUrl" ? existing.coverLetterUrl : null,
},
]);
return updated;
},
// Persist AI-owned enrichment (match score + freeform metadata). Separate from the editable
// update path so these fields are only ever written by the AI procedures.
setAiResult: async (input: {
+47
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { buildMcpServerCard } from "./mcp-server-card";
import { MCP_TOOL_NAME } from "./mcp-tool-names";
import { TOOL_META } from "./tool-meta";
describe("buildMcpServerCard", () => {
const card = buildMcpServerCard("1.2.3");
@@ -41,6 +42,15 @@ describe("buildMcpServerCard", () => {
expect(tool?.annotations?.readOnlyHint).toBe(true);
});
it("advertises application tracker tools", () => {
const names = card.tools.map((tool) => tool.name);
expect(names).toContain("list_applications");
expect(names).toContain("create_application");
expect(names).toContain("attach_application_document");
expect(names).toContain("tailor_resume_for_application");
});
it("declares a JSON Schema input for every tool", () => {
for (const tool of card.tools) {
expect(tool.inputSchema, tool.name).toBeDefined();
@@ -71,4 +81,41 @@ describe("buildMcpServerCard", () => {
const props = card.configurationSchema.properties as Record<string, unknown>;
expect(props.apiKey).toBeDefined();
});
it("matches the create/update application archived contract", () => {
const create = TOOL_META[MCP_TOOL_NAME.createApplication].inputSchema;
const update = TOOL_META[MCP_TOOL_NAME.updateApplication].inputSchema;
expect(create.safeParse({ company: "Acme", role: "Engineer", archived: true }).success).toBe(false);
expect(update.safeParse({ id: "app-1", archived: true }).success).toBe(true);
});
it("accepts only http/https application source URLs", () => {
const create = TOOL_META[MCP_TOOL_NAME.createApplication].inputSchema;
const autofill = TOOL_META[MCP_TOOL_NAME.autofillApplicationFromJob].inputSchema;
expect(create.safeParse({ company: "Acme", role: "Engineer", sourceUrl: "https://example.com/job" }).success).toBe(
true,
);
expect(autofill.safeParse({ sourceUrl: "http://example.com/job" }).success).toBe(true);
expect(create.safeParse({ company: "Acme", role: "Engineer", sourceUrl: "ftp://example.com/job" }).success).toBe(
false,
);
expect(autofill.safeParse({ sourceUrl: "javascript:alert(1)" }).success).toBe(false);
});
it("rejects application document payloads above 10MB decoded", () => {
const schema = TOOL_META[MCP_TOOL_NAME.attachApplicationDocument].inputSchema;
const tooLargePdf = Buffer.alloc(10 * 1024 * 1024 + 1, 0).toString("base64");
expect(
schema.safeParse({
id: "app-1",
kind: "resume",
fileName: "resume.pdf",
contentType: "application/pdf",
dataBase64: tooLargePdf,
}).success,
).toBe(false);
});
});
+17
View File
@@ -14,4 +14,21 @@ export const MCP_TOOL_NAME = {
lockResume: "lock_resume",
unlockResume: "unlock_resume",
getResumeStatistics: "get_resume_statistics",
listApplications: "list_applications",
readApplication: "read_application",
listApplicationTags: "list_application_tags",
getApplicationStats: "get_application_stats",
createApplication: "create_application",
updateApplication: "update_application",
addApplicationNote: "add_application_note",
deleteApplication: "delete_application",
bulkUpdateApplications: "bulk_update_applications",
bulkDeleteApplications: "bulk_delete_applications",
importApplications: "import_applications",
attachApplicationDocument: "attach_application_document",
removeApplicationDocument: "remove_application_document",
autofillApplicationFromJob: "autofill_application_from_job",
scoreApplicationMatch: "score_application_match",
tailorResumeForApplication: "tailor_resume_for_application",
draftApplicationMessage: "draft_application_message",
} as const;
+26 -1
View File
@@ -15,6 +15,14 @@ describe("MCP_TOOL_NAME", () => {
expect(MCP_TOOL_NAME.patchResume).toBe("apply_resume_patch");
});
it("uses canonical application tool names", () => {
expect(MCP_TOOL_NAME.listApplications).toBe("list_applications");
expect(MCP_TOOL_NAME.readApplication).toBe("read_application");
expect(MCP_TOOL_NAME.createApplication).toBe("create_application");
expect(MCP_TOOL_NAME.attachApplicationDocument).toBe("attach_application_document");
expect(MCP_TOOL_NAME.autofillApplicationFromJob).toBe("autofill_application_from_job");
});
it("uses unique values for every tool", () => {
const values = Object.values(MCP_TOOL_NAME);
expect(new Set(values).size).toBe(values.length);
@@ -35,6 +43,10 @@ describe("TOOL_ANNOTATIONS", () => {
MCP_TOOL_NAME.getResume,
MCP_TOOL_NAME.getResumeAnalysis,
MCP_TOOL_NAME.getResumeStatistics,
MCP_TOOL_NAME.listApplications,
MCP_TOOL_NAME.readApplication,
MCP_TOOL_NAME.listApplicationTags,
MCP_TOOL_NAME.getApplicationStats,
];
for (const name of readOnlyTools) {
const annotations = TOOL_ANNOTATIONS[name];
@@ -58,6 +70,14 @@ describe("TOOL_ANNOTATIONS", () => {
expect(annotations.readOnlyHint).toBe(false);
});
it("marks application delete tools as destructive", () => {
for (const name of [MCP_TOOL_NAME.deleteApplication, MCP_TOOL_NAME.bulkDeleteApplications]) {
const annotations = TOOL_ANNOTATIONS[name];
expect(annotations.readOnlyHint, name).toBe(false);
expect(annotations.destructiveHint, name).toBe(true);
}
});
it("marks creation/import/duplicate as non-readonly and non-idempotent", () => {
for (const name of [
MCP_TOOL_NAME.createResume,
@@ -82,8 +102,13 @@ describe("TOOL_ANNOTATIONS", () => {
}
});
it("marks only job-posting autofill as open-world", () => {
expect(TOOL_ANNOTATIONS[MCP_TOOL_NAME.autofillApplicationFromJob].openWorldHint).toBe(true);
});
it("declares no tools as open-world by default", () => {
for (const annotations of Object.values(TOOL_ANNOTATIONS)) {
for (const [name, annotations] of Object.entries(TOOL_ANNOTATIONS)) {
if (name === MCP_TOOL_NAME.autofillApplicationFromJob) continue;
expect(annotations.openWorldHint).toBe(false);
}
});
+23
View File
@@ -16,6 +16,12 @@ const READ_NON_IDEMPOTENT: ToolAnnotations = {
idempotentHint: false,
openWorldHint: false,
};
const READ_OPEN_WORLD_NON_IDEMPOTENT: ToolAnnotations = {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true,
};
const WRITE_NON_IDEMPOTENT: ToolAnnotations = {
readOnlyHint: false,
destructiveHint: false,
@@ -51,4 +57,21 @@ export const TOOL_ANNOTATIONS: Record<McpRegisteredToolName, ToolAnnotations> =
[MCP_TOOL_NAME.lockResume]: WRITE_IDEMPOTENT,
[MCP_TOOL_NAME.unlockResume]: WRITE_IDEMPOTENT,
[MCP_TOOL_NAME.getResumeStatistics]: READ_IDEMPOTENT,
[MCP_TOOL_NAME.listApplications]: READ_IDEMPOTENT,
[MCP_TOOL_NAME.readApplication]: READ_IDEMPOTENT,
[MCP_TOOL_NAME.listApplicationTags]: READ_IDEMPOTENT,
[MCP_TOOL_NAME.getApplicationStats]: READ_IDEMPOTENT,
[MCP_TOOL_NAME.createApplication]: WRITE_NON_IDEMPOTENT,
[MCP_TOOL_NAME.updateApplication]: WRITE_IDEMPOTENT,
[MCP_TOOL_NAME.addApplicationNote]: WRITE_NON_IDEMPOTENT,
[MCP_TOOL_NAME.deleteApplication]: WRITE_DESTRUCTIVE,
[MCP_TOOL_NAME.bulkUpdateApplications]: WRITE_IDEMPOTENT,
[MCP_TOOL_NAME.bulkDeleteApplications]: WRITE_DESTRUCTIVE,
[MCP_TOOL_NAME.importApplications]: WRITE_NON_IDEMPOTENT,
[MCP_TOOL_NAME.attachApplicationDocument]: WRITE_NON_IDEMPOTENT,
[MCP_TOOL_NAME.removeApplicationDocument]: WRITE_IDEMPOTENT,
[MCP_TOOL_NAME.autofillApplicationFromJob]: READ_OPEN_WORLD_NON_IDEMPOTENT,
[MCP_TOOL_NAME.scoreApplicationMatch]: WRITE_NON_IDEMPOTENT,
[MCP_TOOL_NAME.tailorResumeForApplication]: WRITE_NON_IDEMPOTENT,
[MCP_TOOL_NAME.draftApplicationMessage]: READ_NON_IDEMPOTENT,
};
+187
View File
@@ -4,11 +4,71 @@
*/
import z from "zod";
import { resumePatchOperationsSchema } from "@reactive-resume/ai/tools/resume-tool-contracts";
import { applicationStatusSchema, contactSchema } from "@reactive-resume/schema/applications/data";
import { MCP_TOOL_NAME as T } from "./mcp-tool-names";
import { TOOL_ANNOTATIONS } from "./tool-annotations";
const MAX_APPLICATION_DOCUMENT_BYTES = 10 * 1024 * 1024;
// ponytail: shared schema fragment; exported so server-card can re-use without re-importing
const resumeIdSchema = z.string().min(1).describe(`Resume ID. Use \`${T.listResumes}\` to find valid IDs.`);
const applicationIdSchema = z
.string()
.min(1)
.describe(`Application ID. Use \`${T.listApplications}\` to find valid IDs.`);
const applicationDocumentKindSchema = z.enum(["resume", "cover-letter"]);
const httpUrlSchema = z
.string()
.trim()
.refine((value) => {
try {
const parsed = new URL(value);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}, "URL must use http or https.");
const pdfBase64Schema = z
.string()
.min(1)
.refine((value) => Buffer.from(value, "base64").byteLength <= MAX_APPLICATION_DOCUMENT_BYTES, {
message: "Decoded PDF must be 10MB or smaller.",
})
.describe("Base64-encoded PDF bytes. Only application/pdf documents up to 10MB are accepted.");
const applicationMutableFieldsSchema = {
company: z.string().min(1).optional().describe("Company name."),
role: z.string().min(1).optional().describe("Role or job title."),
status: applicationStatusSchema.optional().describe("Pipeline stage."),
location: z.string().nullable().optional(),
salary: z.string().nullable().optional(),
source: z.string().nullable().optional(),
sourceUrl: httpUrlSchema.nullable().optional(),
jobDescription: z.string().max(20_000).nullable().optional(),
notes: z.string().nullable().optional(),
resumeId: z.string().nullable().optional(),
resumeFileUrl: z.string().nullable().optional(),
resumeFileName: z.string().nullable().optional(),
coverLetterUrl: z.string().nullable().optional(),
coverLetterName: z.string().nullable().optional(),
followUpAt: z
.string()
.datetime({ offset: true })
.nullable()
.optional()
.describe("Follow-up timestamp in ISO 8601 format."),
followUpNote: z.string().nullable().optional(),
contacts: z.array(contactSchema).optional(),
tags: z.array(z.string()).optional(),
} as const;
const createApplicationSchema = z
.object({
...applicationMutableFieldsSchema,
company: z.string().min(1).describe("Company name."),
role: z.string().min(1).describe("Role or job title."),
})
.strict();
export const TOOL_META = {
[T.listResumes]: {
@@ -234,4 +294,131 @@ export const TOOL_META = {
inputSchema: z.object({ id: resumeIdSchema }),
annotations: TOOL_ANNOTATIONS[T.getResumeStatistics],
},
[T.listApplications]: {
title: "List Applications",
description:
"List job applications for the authenticated account. Use this before reading or updating existing applications.",
inputSchema: z.object({
status: applicationStatusSchema.optional(),
tags: z.array(z.string()).optional().default([]),
includeArchived: z.boolean().optional().default(false),
}),
annotations: TOOL_ANNOTATIONS[T.listApplications],
},
[T.readApplication]: {
title: "Read Application",
description: "Read one full job application, including contacts, document URLs, follow-up details, and timeline.",
inputSchema: z.object({ id: applicationIdSchema }),
annotations: TOOL_ANNOTATIONS[T.readApplication],
},
[T.listApplicationTags]: {
title: "List Application Tags",
description: "Return every distinct tag used across job applications.",
inputSchema: z.object({}),
annotations: TOOL_ANNOTATIONS[T.listApplicationTags],
},
[T.getApplicationStats]: {
title: "Get Application Stats",
description: "Return aggregate application counts by pipeline stage and source for insights.",
inputSchema: z.object({}),
annotations: TOOL_ANNOTATIONS[T.getApplicationStats],
},
[T.createApplication]: {
title: "Create Application",
description: "Create a tracked job application. Company and role are required.",
inputSchema: createApplicationSchema,
annotations: TOOL_ANNOTATIONS[T.createApplication],
},
[T.updateApplication]: {
title: "Update Application",
description:
"Update application fields, move stages, archive/unarchive, edit contacts, follow-up, tags, or linked resume.",
inputSchema: z.object({
id: applicationIdSchema,
...applicationMutableFieldsSchema,
archived: z.boolean().optional().describe("Whether the application is hidden from active views."),
}),
annotations: TOOL_ANNOTATIONS[T.updateApplication],
},
[T.addApplicationNote]: {
title: "Add Application Note",
description: "Append a free-text note to an application's timeline.",
inputSchema: z.object({ id: applicationIdSchema, text: z.string().min(1) }),
annotations: TOOL_ANNOTATIONS[T.addApplicationNote],
},
[T.deleteApplication]: {
title: "Delete Application",
description: "Permanently delete one job application and its owned uploaded documents.",
inputSchema: z.object({ id: applicationIdSchema }),
annotations: TOOL_ANNOTATIONS[T.deleteApplication],
},
[T.bulkUpdateApplications]: {
title: "Bulk Update Applications",
description: "Move, archive/unarchive, or add tags to multiple applications.",
inputSchema: z.object({
ids: z.array(z.string()).min(1),
status: applicationStatusSchema.optional(),
archived: z.boolean().optional(),
addTags: z.array(z.string()).optional(),
}),
annotations: TOOL_ANNOTATIONS[T.bulkUpdateApplications],
},
[T.bulkDeleteApplications]: {
title: "Bulk Delete Applications",
description: "Permanently delete multiple applications.",
inputSchema: z.object({ ids: z.array(z.string()).min(1) }),
annotations: TOOL_ANNOTATIONS[T.bulkDeleteApplications],
},
[T.importApplications]: {
title: "Import Applications",
description: "Bulk-create application rows parsed from CSV or another source. Maximum 500 items.",
inputSchema: z.object({ items: z.array(createApplicationSchema).min(1).max(500) }),
annotations: TOOL_ANNOTATIONS[T.importApplications],
},
[T.attachApplicationDocument]: {
title: "Attach Application Document",
description: "Attach a sent resume or cover-letter PDF to an application using base64-encoded PDF bytes.",
inputSchema: z.object({
id: applicationIdSchema,
kind: applicationDocumentKindSchema,
fileName: z.string().min(1),
contentType: z.literal("application/pdf"),
dataBase64: pdfBase64Schema,
}),
annotations: TOOL_ANNOTATIONS[T.attachApplicationDocument],
},
[T.removeApplicationDocument]: {
title: "Remove Application Document",
description: "Remove a sent resume or cover-letter PDF from an application.",
inputSchema: z.object({ id: applicationIdSchema, kind: applicationDocumentKindSchema }),
annotations: TOOL_ANNOTATIONS[T.removeApplicationDocument],
},
[T.autofillApplicationFromJob]: {
title: "Autofill Application From Job",
description:
"Use AI to extract company, role, location, salary, and job description from a job URL or pasted posting.",
inputSchema: z.object({
sourceUrl: httpUrlSchema.optional(),
jobDescription: z.string().max(20_000).optional(),
}),
annotations: TOOL_ANNOTATIONS[T.autofillApplicationFromJob],
},
[T.scoreApplicationMatch]: {
title: "Score Application Match",
description: "Score the linked resume against the application's job description and persist match metadata.",
inputSchema: z.object({ id: applicationIdSchema }),
annotations: TOOL_ANNOTATIONS[T.scoreApplicationMatch],
},
[T.tailorResumeForApplication]: {
title: "Tailor Resume For Application",
description: "Create and link a tailored copy of the application's linked resume.",
inputSchema: z.object({ id: applicationIdSchema }),
annotations: TOOL_ANNOTATIONS[T.tailorResumeForApplication],
},
[T.draftApplicationMessage]: {
title: "Draft Application Message",
description: "Draft either a cover letter or recruiter follow-up from application and resume context.",
inputSchema: z.object({ id: applicationIdSchema, kind: z.enum(["cover-letter", "follow-up"]) }),
annotations: TOOL_ANNOTATIONS[T.draftApplicationMessage],
},
} as const;
+141 -1
View File
@@ -23,7 +23,7 @@ vi.mock("@reactive-resume/env/server", () => ({
const { MCP_TOOL_NAME, registerTools } = await import("./tools");
type ToolHandler = (input: { id: string }) => Promise<{
type ToolHandler = (input: Record<string, unknown>) => Promise<{
content: Array<{ type: "text"; text: string }>;
isError?: boolean;
}>;
@@ -63,6 +63,27 @@ const clientMock = {
setLocked: vi.fn(),
statistics: { getById: vi.fn() },
},
applications: {
list: vi.fn(),
getById: vi.fn(),
tags: vi.fn(),
stats: vi.fn(),
create: vi.fn(),
update: vi.fn(),
addNote: vi.fn(),
delete: vi.fn(),
bulkUpdate: vi.fn(),
bulkDelete: vi.fn(),
import: vi.fn(),
attachDocument: vi.fn(),
removeDocument: vi.fn(),
ai: {
autofill: vi.fn(),
matchScore: vi.fn(),
tailorResume: vi.fn(),
draftMessage: vi.fn(),
},
},
};
describe("registerTools", () => {
@@ -104,4 +125,123 @@ describe("registerTools", () => {
it("keeps the tool name stable", () => {
expect(MCP_TOOL_NAME.downloadResumePdf).toBe("download_resume_pdf");
});
it("registers application tracker tools", () => {
const { server, registered } = makeFakeServer();
registerTools(server as never, clientMock as never, new Headers());
const names = registered.map((item) => item.name);
expect(names).toContain("list_applications");
expect(names).toContain("create_application");
expect(names).toContain("attach_application_document");
expect(names).toContain("draft_application_message");
});
it("lists applications as JSON", async () => {
clientMock.applications.list.mockResolvedValueOnce([{ id: "app-1", company: "Acme", role: "Engineer" }]);
const { server, registered } = makeFakeServer();
registerTools(server as never, clientMock as never, new Headers());
const tool = registered.find((item) => item.name === "list_applications")!;
const result = await tool.handler({ includeArchived: true, tags: ["remote"] });
expect(clientMock.applications.list).toHaveBeenCalledWith({ includeArchived: true, tags: ["remote"] });
expect(JSON.parse(result.content[0]!.text)).toEqual([{ id: "app-1", company: "Acme", role: "Engineer" }]);
});
it("creates applications through the router client", async () => {
clientMock.applications.create.mockResolvedValueOnce("app-1");
const { server, registered } = makeFakeServer();
registerTools(server as never, clientMock as never, new Headers());
const tool = registered.find((item) => item.name === "create_application")!;
const result = await tool.handler({
company: "Acme",
role: "Engineer",
status: "saved",
followUpAt: "2026-07-10T09:30:00.000Z",
});
expect(clientMock.applications.create).toHaveBeenCalledWith({
company: "Acme",
role: "Engineer",
status: "saved",
followUpAt: new Date("2026-07-10T09:30:00.000Z"),
});
expect(JSON.parse(result.content[0]!.text)).toEqual({ id: "app-1" });
});
it("updates applications with followUpAt coerced to Date", async () => {
clientMock.applications.update.mockResolvedValueOnce({ id: "app-1", company: "Acme" });
const { server, registered } = makeFakeServer();
registerTools(server as never, clientMock as never, new Headers());
const tool = registered.find((item) => item.name === "update_application")!;
await tool.handler({ id: "app-1", followUpAt: "2026-07-11T10:15:00.000Z" });
expect(clientMock.applications.update).toHaveBeenCalledWith({
id: "app-1",
followUpAt: new Date("2026-07-11T10:15:00.000Z"),
});
});
it("imports applications with followUpAt coerced to Date and null preserved", async () => {
clientMock.applications.import.mockResolvedValueOnce({ imported: 2 });
const { server, registered } = makeFakeServer();
registerTools(server as never, clientMock as never, new Headers());
const tool = registered.find((item) => item.name === "import_applications")!;
const result = await tool.handler({
items: [
{ company: "Acme", role: "Engineer", followUpAt: "2026-07-12T12:00:00.000Z" },
{ company: "Beta", role: "Designer", followUpAt: null },
],
});
expect(clientMock.applications.import).toHaveBeenCalledWith({
items: [
{ company: "Acme", role: "Engineer", followUpAt: new Date("2026-07-12T12:00:00.000Z") },
{ company: "Beta", role: "Designer", followUpAt: null },
],
});
expect(JSON.parse(result.content[0]!.text)).toEqual({ imported: 2 });
});
it("attaches a base64 PDF document through the router client", async () => {
clientMock.applications.attachDocument.mockResolvedValueOnce({ id: "app-1", resumeFileName: "resume.pdf" });
const { server, registered } = makeFakeServer();
registerTools(server as never, clientMock as never, new Headers());
const tool = registered.find((item) => item.name === "attach_application_document")!;
const result = await tool.handler({
id: "app-1",
kind: "resume",
fileName: "resume.pdf",
contentType: "application/pdf",
dataBase64: Buffer.from("%PDF-1.4").toString("base64"),
});
const call = clientMock.applications.attachDocument.mock.calls[0]?.[0] as { file: File };
expect(call.file).toBeInstanceOf(File);
expect(call.file.name).toBe("resume.pdf");
expect(call.file.type).toBe("application/pdf");
expect(JSON.parse(result.content[0]!.text)).toEqual({ id: "app-1", resumeFileName: "resume.pdf" });
});
it("rejects non-PDF application document attachments before calling the client", async () => {
const { server, registered } = makeFakeServer();
registerTools(server as never, clientMock as never, new Headers());
const tool = registered.find((item) => item.name === "attach_application_document")!;
const result = await tool.handler({
id: "app-1",
kind: "resume",
fileName: "resume.txt",
contentType: "text/plain",
dataBase64: Buffer.from("hello").toString("base64"),
});
expect(result.isError).toBe(true);
expect(clientMock.applications.attachDocument).not.toHaveBeenCalled();
});
});
+180
View File
@@ -4,6 +4,7 @@ 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 type z from "zod";
import { Buffer } from "node:buffer";
import { resolveUserFromRequestHeaders } from "@reactive-resume/api/context";
import { createResumePdfDownloadUrl } from "@reactive-resume/api/features/resume/export";
import { env } from "@reactive-resume/env/server";
@@ -57,6 +58,28 @@ function text(value: string): CallToolResult {
return { content: [{ type: "text", text: value }] };
}
function json(value: unknown): CallToolResult {
return text(JSON.stringify(value, null, 2));
}
function fileFromBase64(input: { fileName: string; contentType: string; dataBase64: string }): File {
if (input.contentType !== "application/pdf") throw new Error("Application documents must be PDF files.");
const bytes = Buffer.from(input.dataBase64, "base64");
if (bytes.length === 0) throw new Error("Application document cannot be empty.");
return new File([bytes], input.fileName, { type: input.contentType });
}
function coerceFollowUpAt(input: Record<string, unknown>): Record<string, unknown> {
if (!("followUpAt" in input)) return input;
const followUpAt = input.followUpAt;
if (followUpAt === undefined || followUpAt === null || followUpAt instanceof Date) return input;
return { ...input, followUpAt: new Date(String(followUpAt)) };
}
function buildResumeShareUrl(username: string, slug: string): string {
const base = env.APP_URL.replace(/\/$/, "");
return `${base}/${encodeURIComponent(username)}/${encodeURIComponent(slug)}`;
@@ -339,4 +362,161 @@ export function registerTools(server: McpServer, client: RouterClient<typeof rou
return text(JSON.stringify(stats, null, 2));
}),
);
// ── Applications ──────────────────────────────────────────────
server.registerTool(
T.listApplications,
TOOL_META[T.listApplications],
withErrorHandling("listing applications", async (params) => json(await client.applications.list(params as never))),
);
server.registerTool(
T.readApplication,
TOOL_META[T.readApplication],
withErrorHandling("reading application", async ({ id }: { id: string }) =>
json(await client.applications.getById({ id })),
),
);
server.registerTool(
T.listApplicationTags,
TOOL_META[T.listApplicationTags],
withErrorHandling("listing application tags", async () => json(await client.applications.tags())),
);
server.registerTool(
T.getApplicationStats,
TOOL_META[T.getApplicationStats],
withErrorHandling("getting application stats", async () => json(await client.applications.stats())),
);
server.registerTool(
T.createApplication,
TOOL_META[T.createApplication],
withErrorHandling("creating application", async (params) => {
const id = await client.applications.create(coerceFollowUpAt(params as Record<string, unknown>) as never);
return json({ id });
}),
);
server.registerTool(
T.updateApplication,
TOOL_META[T.updateApplication],
withErrorHandling("updating application", async (params) => {
return json(await client.applications.update(coerceFollowUpAt(params as Record<string, unknown>) as never));
}),
);
server.registerTool(
T.addApplicationNote,
TOOL_META[T.addApplicationNote],
withErrorHandling("adding application note", async ({ id, text: noteText }: { id: string; text: string }) => {
return json(await client.applications.addNote({ id, text: noteText }));
}),
);
server.registerTool(
T.deleteApplication,
TOOL_META[T.deleteApplication],
withErrorHandling("deleting application", async ({ id }: { id: string }) => {
await client.applications.delete({ id });
return text(`Deleted application (${id}).`);
}),
);
server.registerTool(
T.bulkUpdateApplications,
TOOL_META[T.bulkUpdateApplications],
withErrorHandling("bulk updating applications", async (params) =>
json(await client.applications.bulkUpdate(params as never)),
),
);
server.registerTool(
T.bulkDeleteApplications,
TOOL_META[T.bulkDeleteApplications],
withErrorHandling("bulk deleting applications", async ({ ids }: { ids: string[] }) => {
return json(await client.applications.bulkDelete({ ids }));
}),
);
server.registerTool(
T.importApplications,
TOOL_META[T.importApplications],
withErrorHandling("importing applications", async (params) => {
const input = params as { items: Array<Record<string, unknown>> };
return json(
await client.applications.import({ items: input.items.map((item) => coerceFollowUpAt(item)) } as never),
);
}),
);
server.registerTool(
T.attachApplicationDocument,
TOOL_META[T.attachApplicationDocument],
withErrorHandling(
"attaching application document",
async ({
id,
kind,
fileName,
contentType,
dataBase64,
}: {
id: string;
kind: "resume" | "cover-letter";
fileName: string;
contentType: string;
dataBase64: string;
}) => {
const file = fileFromBase64({ fileName, contentType, dataBase64 });
return json(await client.applications.attachDocument({ id, kind, file }));
},
),
);
server.registerTool(
T.removeApplicationDocument,
TOOL_META[T.removeApplicationDocument],
withErrorHandling(
"removing application document",
async ({ id, kind }: { id: string; kind: "resume" | "cover-letter" }) => {
return json(await client.applications.removeDocument({ id, kind }));
},
),
);
server.registerTool(
T.autofillApplicationFromJob,
TOOL_META[T.autofillApplicationFromJob],
withErrorHandling("autofilling application from job", async (params) =>
json(await client.applications.ai.autofill(params as never)),
),
);
server.registerTool(
T.scoreApplicationMatch,
TOOL_META[T.scoreApplicationMatch],
withErrorHandling("scoring application match", async ({ id }: { id: string }) => {
return json(await client.applications.ai.matchScore({ id }));
}),
);
server.registerTool(
T.tailorResumeForApplication,
TOOL_META[T.tailorResumeForApplication],
withErrorHandling("tailoring resume for application", async ({ id }: { id: string }) => {
return json(await client.applications.ai.tailorResume({ id }));
}),
);
server.registerTool(
T.draftApplicationMessage,
TOOL_META[T.draftApplicationMessage],
withErrorHandling(
"drafting application message",
async ({ id, kind }: { id: string; kind: "cover-letter" | "follow-up" }) =>
json(await client.applications.ai.draftMessage({ id, kind })),
),
);
}