mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-24 17:03:55 +10:00
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:
@@ -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({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 })),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user