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
+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 })),
),
);
}