mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-26 01:44:53 +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:
@@ -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