mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-24 23:32:19 +10:00
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.
1373 lines
46 KiB
Markdown
1373 lines
46 KiB
Markdown
# Application Tracker REST and MCP Parity Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Expose every shipped Application Tracker capability through public REST/OpenAPI, Reactive Resume MCP tools, and the bundled `resume-builder` skill.
|
|
|
|
**Architecture:** Reuse the existing `packages/api/src/features/applications` oRPC procedures and `applicationService` as the single business layer. Add the one missing public REST capability, application document attach/remove, then register MCP tools that call the injected in-process oRPC `RouterClient` rather than importing database code.
|
|
|
|
**Tech Stack:** TypeScript, oRPC, Zod v4, Drizzle, Vitest, MCP TypeScript SDK, existing Reactive Resume storage service, Biome.
|
|
|
|
## Global Constraints
|
|
|
|
- Campaign tracking is out of scope.
|
|
- Fixed stages remain `saved`, `applied`, `screening`, `interview`, `offer`, `rejected`.
|
|
- Public REST endpoints live under `/api/openapi` and are generated from oRPC route metadata.
|
|
- Generic storage routes stay tagged `Internal`; public application document upload/remove belongs under the Applications tag.
|
|
- Application documents are PDFs only.
|
|
- Document uploads keep the existing 10MB storage limit.
|
|
- MCP tool names use canonical unprefixed `snake_case`.
|
|
- MCP handlers use the injected in-process oRPC `RouterClient`.
|
|
- No new dependencies.
|
|
- Do not refactor the Application Tracker UI.
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
- Modify `packages/api/src/dto/application.ts`: add document kind/upload/delete DTO schemas.
|
|
- Modify `packages/api/src/dto/application.test.ts`: test document DTO constraints.
|
|
- Modify `packages/api/src/features/applications/service.ts`: add `attachDocument` and `removeDocument` service methods, reusing existing ownership and storage cleanup helpers.
|
|
- Modify `packages/api/src/features/applications/service.test.ts`: cover document attachment/removal behavior and storage cleanup.
|
|
- Modify `packages/api/src/features/applications/crud.ts`: add public REST document routes.
|
|
- Modify `packages/api/src/features/applications/router.ts`: expose document route procedures under `applications`.
|
|
- Modify `packages/mcp/src/mcp-tool-names.ts`: add application MCP tool names.
|
|
- Modify `packages/mcp/src/tool-meta.ts`: add application tool input schemas and metadata.
|
|
- Modify `packages/mcp/src/tool-annotations.ts`: add annotations for new tools.
|
|
- Modify `packages/mcp/src/tools.ts`: register application tool handlers.
|
|
- Modify `packages/mcp/src/tool-annotations.test.ts`, `packages/mcp/src/mcp-server-card.test.ts`, and `packages/mcp/src/tools.test.ts`: cover registration and handler behavior.
|
|
- Modify `skills/resume-builder/SKILL.md`: tell agents application MCP tools exist.
|
|
- Modify `docs/guides/using-the-mcp-server.mdx`: document application tools and examples.
|
|
- Modify `docs/use-cases/api-mcp-resume-automation.mdx`: expand use case from resume-only automation to resume plus application tracking.
|
|
- Modify `docs/spec.json`: refresh OpenAPI output after API route changes.
|
|
|
|
---
|
|
|
|
### Task 1: Public REST Document Attach/Remove
|
|
|
|
**Files:**
|
|
- Modify: `packages/api/src/dto/application.ts`
|
|
- Modify: `packages/api/src/dto/application.test.ts`
|
|
- Modify: `packages/api/src/features/applications/service.ts`
|
|
- Modify: `packages/api/src/features/applications/service.test.ts`
|
|
- Modify: `packages/api/src/features/applications/crud.ts`
|
|
- Modify: `packages/api/src/features/applications/router.ts`
|
|
|
|
**Interfaces:**
|
|
- Consumes: existing `applicationService.update(input)` and existing storage helpers in `packages/api/src/features/storage/service.ts`.
|
|
- Produces:
|
|
- `applicationDto.attachDocument.input`: `{ id: string; kind: "resume" | "cover-letter"; file: File }`
|
|
- `applicationDto.removeDocument.input`: `{ id: string; kind: "resume" | "cover-letter" }`
|
|
- `applicationService.attachDocument(input: { id: string; userId: string; kind: ApplicationDocumentKind; fileName: string; data: Uint8Array; contentType: string }): Promise<ApplicationWithoutUserId>`
|
|
- `applicationService.removeDocument(input: { id: string; userId: string; kind: ApplicationDocumentKind }): Promise<ApplicationWithoutUserId>`
|
|
- `crudRouter.attachDocument`
|
|
- `crudRouter.removeDocument`
|
|
|
|
- [ ] **Step 1: Add failing DTO tests**
|
|
|
|
Append to `packages/api/src/dto/application.test.ts`:
|
|
|
|
```ts
|
|
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();
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run DTO test to verify it fails**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter @reactive-resume/api test -- src/dto/application.test.ts
|
|
```
|
|
|
|
Expected: FAIL because `applicationDto.attachDocument` is undefined.
|
|
|
|
- [ ] **Step 3: Add document DTO schemas**
|
|
|
|
In `packages/api/src/dto/application.ts`, add near the top-level constants:
|
|
|
|
```ts
|
|
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.");
|
|
```
|
|
|
|
Add to `applicationDto`:
|
|
|
|
```ts
|
|
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 }),
|
|
},
|
|
```
|
|
|
|
Also export the kind type at the end of the file:
|
|
|
|
```ts
|
|
export type ApplicationDocumentKind = z.infer<typeof applicationDocumentKindSchema>;
|
|
```
|
|
|
|
- [ ] **Step 4: Run DTO tests to verify they pass**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter @reactive-resume/api test -- src/dto/application.test.ts
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Add failing service tests**
|
|
|
|
Modify the hoisted mocks in `packages/api/src/features/applications/service.test.ts`:
|
|
|
|
```ts
|
|
const storageDeleteMock = vi.hoisted(() => vi.fn());
|
|
const uploadFileMock = vi.hoisted(() => vi.fn());
|
|
```
|
|
|
|
Replace the storage service mock with:
|
|
|
|
```ts
|
|
vi.mock("../storage/service", () => ({
|
|
getStorageService: () => ({ delete: storageDeleteMock }),
|
|
uploadFile: uploadFileMock,
|
|
}));
|
|
```
|
|
|
|
Add to `beforeEach`:
|
|
|
|
```ts
|
|
uploadFileMock.mockReset();
|
|
uploadFileMock.mockResolvedValue({ url: "/api/uploads/user-1/pictures/new.pdf", key: "uploads/user-1/pictures/new.pdf" });
|
|
```
|
|
|
|
Append tests:
|
|
|
|
```ts
|
|
describe("applicationService.attachDocument", () => {
|
|
it("uploads a PDF resume document and stores it on the application", async () => {
|
|
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();
|
|
});
|
|
});
|
|
|
|
describe("applicationService.removeDocument", () => {
|
|
it("clears and deletes an owned cover letter document", async () => {
|
|
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");
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 6: Run service tests to verify they fail**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter @reactive-resume/api test -- src/features/applications/service.test.ts
|
|
```
|
|
|
|
Expected: FAIL because `applicationService.attachDocument` and `applicationService.removeDocument` are undefined.
|
|
|
|
- [ ] **Step 7: Add service implementation**
|
|
|
|
In `packages/api/src/features/applications/service.ts`, update imports:
|
|
|
|
```ts
|
|
import type { ApplicationDocumentKind } from "../../dto/application";
|
|
import { getStorageService, uploadFile } from "../storage/service";
|
|
```
|
|
|
|
Add this helper near `deleteApplicationAttachments`:
|
|
|
|
```ts
|
|
function documentFields(kind: ApplicationDocumentKind) {
|
|
return kind === "resume"
|
|
? ({
|
|
url: "resumeFileUrl",
|
|
name: "resumeFileName",
|
|
} as const)
|
|
: ({
|
|
url: "coverLetterUrl",
|
|
name: "coverLetterName",
|
|
} as const);
|
|
}
|
|
```
|
|
|
|
Add methods to `applicationService` after `update`:
|
|
|
|
```ts
|
|
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;
|
|
},
|
|
```
|
|
|
|
- [ ] **Step 8: Run service tests to verify they pass**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter @reactive-resume/api test -- src/features/applications/service.test.ts
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 9: Add REST routes**
|
|
|
|
In `packages/api/src/features/applications/crud.ts`, add methods before `stats`:
|
|
|
|
```ts
|
|
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.",
|
|
requestBodyHint: "form-data",
|
|
})
|
|
.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 });
|
|
}),
|
|
```
|
|
|
|
In `packages/api/src/features/applications/router.ts`, add:
|
|
|
|
```ts
|
|
attachDocument: crudRouter.attachDocument,
|
|
removeDocument: crudRouter.removeDocument,
|
|
```
|
|
|
|
- [ ] **Step 10: Run API typecheck**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter @reactive-resume/api typecheck
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 11: Commit REST/API task**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
git add packages/api/src/dto/application.ts packages/api/src/dto/application.test.ts packages/api/src/features/applications/service.ts packages/api/src/features/applications/service.test.ts packages/api/src/features/applications/crud.ts packages/api/src/features/applications/router.ts
|
|
git commit -m "feat: add application document API"
|
|
```
|
|
|
|
Expected: commit succeeds.
|
|
|
|
---
|
|
|
|
### Task 2: MCP Tool Names, Metadata, and Annotations
|
|
|
|
**Files:**
|
|
- Modify: `packages/mcp/src/mcp-tool-names.ts`
|
|
- Modify: `packages/mcp/src/tool-meta.ts`
|
|
- Modify: `packages/mcp/src/tool-annotations.ts`
|
|
- Modify: `packages/mcp/src/tool-annotations.test.ts`
|
|
- Modify: `packages/mcp/src/mcp-server-card.test.ts`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `applicationStatusSchema` and `contactSchema` from `@reactive-resume/schema/applications/data`.
|
|
- Produces: new `MCP_TOOL_NAME` values and `TOOL_META` entries used by `registerTools` in Task 3.
|
|
|
|
- [ ] **Step 1: Add failing MCP metadata tests**
|
|
|
|
In `packages/mcp/src/tool-annotations.test.ts`, add after the canonical read/patch names test:
|
|
|
|
```ts
|
|
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");
|
|
});
|
|
```
|
|
|
|
In the read-only annotation test list, add:
|
|
|
|
```ts
|
|
MCP_TOOL_NAME.listApplications,
|
|
MCP_TOOL_NAME.readApplication,
|
|
MCP_TOOL_NAME.listApplicationTags,
|
|
MCP_TOOL_NAME.getApplicationStats,
|
|
```
|
|
|
|
Add tests before the open-world test:
|
|
|
|
```ts
|
|
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 only job-posting autofill as open-world", () => {
|
|
expect(TOOL_ANNOTATIONS[MCP_TOOL_NAME.autofillApplicationFromJob].openWorldHint).toBe(true);
|
|
});
|
|
```
|
|
|
|
In `packages/mcp/src/mcp-server-card.test.ts`, add:
|
|
|
|
```ts
|
|
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");
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run MCP metadata tests to verify they fail**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter @reactive-resume/mcp test -- src/tool-annotations.test.ts src/mcp-server-card.test.ts
|
|
```
|
|
|
|
Expected: FAIL because the new MCP tool names do not exist.
|
|
|
|
- [ ] **Step 3: Add MCP tool names**
|
|
|
|
In `packages/mcp/src/mcp-tool-names.ts`, add these properties to `MCP_TOOL_NAME`:
|
|
|
|
```ts
|
|
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",
|
|
```
|
|
|
|
- [ ] **Step 4: Add MCP metadata schemas**
|
|
|
|
In `packages/mcp/src/tool-meta.ts`, add imports:
|
|
|
|
```ts
|
|
import { applicationStatusSchema, contactSchema } from "@reactive-resume/schema/applications/data";
|
|
```
|
|
|
|
Add shared schemas near `resumeIdSchema`:
|
|
|
|
```ts
|
|
const applicationIdSchema = z.string().min(1).describe(`Application ID. Use \`${T.listApplications}\` to find valid IDs.`);
|
|
const applicationDocumentKindSchema = z.enum(["resume", "cover-letter"]);
|
|
const pdfBase64Schema = z
|
|
.string()
|
|
.min(1)
|
|
.describe("Base64-encoded PDF bytes. Only application/pdf documents are accepted.");
|
|
|
|
const applicationEditableSchema = {
|
|
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."),
|
|
archived: z.boolean().optional().describe("Whether the application is hidden from active views."),
|
|
location: z.string().nullable().optional(),
|
|
salary: z.string().nullable().optional(),
|
|
source: z.string().nullable().optional(),
|
|
sourceUrl: z.string().url().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.coerce.date().nullable().optional(),
|
|
followUpNote: z.string().nullable().optional(),
|
|
contacts: z.array(contactSchema).optional(),
|
|
tags: z.array(z.string()).optional(),
|
|
} as const;
|
|
|
|
const createApplicationSchema = z.object({
|
|
...applicationEditableSchema,
|
|
company: z.string().min(1).describe("Company name."),
|
|
role: z.string().min(1).describe("Role or job title."),
|
|
});
|
|
```
|
|
|
|
Add these `TOOL_META` entries after resume tools or in a separate Applications block:
|
|
|
|
```ts
|
|
[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, ...applicationEditableSchema }),
|
|
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: z.string().url().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],
|
|
},
|
|
```
|
|
|
|
- [ ] **Step 5: Add annotations**
|
|
|
|
In `packages/mcp/src/tool-annotations.ts`, add:
|
|
|
|
```ts
|
|
const READ_OPEN_WORLD_NON_IDEMPOTENT: ToolAnnotations = {
|
|
readOnlyHint: true,
|
|
destructiveHint: false,
|
|
idempotentHint: false,
|
|
openWorldHint: true,
|
|
};
|
|
```
|
|
|
|
Add mappings:
|
|
|
|
```ts
|
|
[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,
|
|
```
|
|
|
|
Update the existing "declares no tools as open-world by default" test to skip `MCP_TOOL_NAME.autofillApplicationFromJob`:
|
|
|
|
```ts
|
|
for (const [name, annotations] of Object.entries(TOOL_ANNOTATIONS)) {
|
|
if (name === MCP_TOOL_NAME.autofillApplicationFromJob) continue;
|
|
expect(annotations.openWorldHint).toBe(false);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Run MCP metadata tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter @reactive-resume/mcp test -- src/tool-annotations.test.ts src/mcp-server-card.test.ts
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 7: Commit metadata task**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
git add packages/mcp/src/mcp-tool-names.ts packages/mcp/src/tool-meta.ts packages/mcp/src/tool-annotations.ts packages/mcp/src/tool-annotations.test.ts packages/mcp/src/mcp-server-card.test.ts
|
|
git commit -m "feat: describe application MCP tools"
|
|
```
|
|
|
|
Expected: commit succeeds.
|
|
|
|
---
|
|
|
|
### Task 3: MCP Application Tool Handlers
|
|
|
|
**Files:**
|
|
- Modify: `packages/mcp/src/tools.ts`
|
|
- Modify: `packages/mcp/src/tools.test.ts`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `MCP_TOOL_NAME` values and `TOOL_META` entries from Task 2.
|
|
- Consumes: API procedures exposed from Task 1.
|
|
- Produces: registered MCP handlers for every application tool.
|
|
|
|
- [ ] **Step 1: Add failing handler tests**
|
|
|
|
In `packages/mcp/src/tools.test.ts`, change `ToolHandler` to accept any object:
|
|
|
|
```ts
|
|
type ToolHandler = (input: Record<string, unknown>) => Promise<{
|
|
content: Array<{ type: "text"; text: string }>;
|
|
isError?: boolean;
|
|
}>;
|
|
```
|
|
|
|
Extend `clientMock` with:
|
|
|
|
```ts
|
|
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(),
|
|
},
|
|
},
|
|
```
|
|
|
|
Add tests:
|
|
|
|
```ts
|
|
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" });
|
|
|
|
expect(clientMock.applications.create).toHaveBeenCalledWith({
|
|
company: "Acme",
|
|
role: "Engineer",
|
|
status: "saved",
|
|
});
|
|
expect(JSON.parse(result.content[0]!.text)).toEqual({ id: "app-1" });
|
|
});
|
|
|
|
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();
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run handler tests to verify they fail**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter @reactive-resume/mcp test -- src/tools.test.ts
|
|
```
|
|
|
|
Expected: FAIL because application tools are not registered.
|
|
|
|
- [ ] **Step 3: Add JSON and File helpers**
|
|
|
|
In `packages/mcp/src/tools.ts`, add an import:
|
|
|
|
```ts
|
|
import { Buffer } from "node:buffer";
|
|
```
|
|
|
|
Add helper near `text()`:
|
|
|
|
```ts
|
|
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 });
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Register read/discovery handlers**
|
|
|
|
In `registerTools`, after resume statistics or in a clearly labelled Applications block, add:
|
|
|
|
```ts
|
|
server.registerTool(
|
|
T.listApplications,
|
|
TOOL_META[T.listApplications],
|
|
withErrorHandling("listing applications", async (params) => {
|
|
return json(await client.applications.list(params as { status?: never; tags?: string[]; includeArchived?: boolean }));
|
|
}),
|
|
);
|
|
|
|
server.registerTool(
|
|
T.readApplication,
|
|
TOOL_META[T.readApplication],
|
|
withErrorHandling("reading application", async ({ id }: { id: string }) => {
|
|
return json(await client.applications.getById({ id }));
|
|
}),
|
|
);
|
|
|
|
server.registerTool(
|
|
T.listApplicationTags,
|
|
TOOL_META[T.listApplicationTags],
|
|
withErrorHandling("listing application tags", async () => {
|
|
return json(await client.applications.tags());
|
|
}),
|
|
);
|
|
|
|
server.registerTool(
|
|
T.getApplicationStats,
|
|
TOOL_META[T.getApplicationStats],
|
|
withErrorHandling("getting application stats", async () => {
|
|
return json(await client.applications.stats());
|
|
}),
|
|
);
|
|
```
|
|
|
|
- [ ] **Step 5: Register write handlers**
|
|
|
|
Add:
|
|
|
|
```ts
|
|
server.registerTool(
|
|
T.createApplication,
|
|
TOOL_META[T.createApplication],
|
|
withErrorHandling("creating application", async (params) => {
|
|
const id = await client.applications.create(params as never);
|
|
return json({ id });
|
|
}),
|
|
);
|
|
|
|
server.registerTool(
|
|
T.updateApplication,
|
|
TOOL_META[T.updateApplication],
|
|
withErrorHandling("updating application", async (params) => {
|
|
return json(await client.applications.update(params 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) => {
|
|
return 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) => {
|
|
return json(await client.applications.import(params as never));
|
|
}),
|
|
);
|
|
```
|
|
|
|
- [ ] **Step 6: Register document handlers**
|
|
|
|
Add:
|
|
|
|
```ts
|
|
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 }));
|
|
},
|
|
),
|
|
);
|
|
```
|
|
|
|
- [ ] **Step 7: Register AI handlers**
|
|
|
|
Add:
|
|
|
|
```ts
|
|
server.registerTool(
|
|
T.autofillApplicationFromJob,
|
|
TOOL_META[T.autofillApplicationFromJob],
|
|
withErrorHandling("autofilling application from job", async (params) => {
|
|
return json(await client.applications.ai.autofill(params as { sourceUrl?: string; jobDescription?: string }));
|
|
}),
|
|
);
|
|
|
|
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" }) => {
|
|
return json(await client.applications.ai.draftMessage({ id, kind }));
|
|
}),
|
|
);
|
|
```
|
|
|
|
- [ ] **Step 8: Run MCP handler tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter @reactive-resume/mcp test -- src/tools.test.ts
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 9: Run MCP typecheck**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter @reactive-resume/mcp typecheck
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 10: Commit MCP handler task**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
git add packages/mcp/src/tools.ts packages/mcp/src/tools.test.ts
|
|
git commit -m "feat: add application MCP tools"
|
|
```
|
|
|
|
Expected: commit succeeds.
|
|
|
|
---
|
|
|
|
### Task 4: Skill, MCP Docs, and OpenAPI Spec
|
|
|
|
**Files:**
|
|
- Modify: `skills/resume-builder/SKILL.md`
|
|
- Modify: `docs/guides/using-the-mcp-server.mdx`
|
|
- Modify: `docs/use-cases/api-mcp-resume-automation.mdx`
|
|
- Modify: `docs/spec.json`
|
|
|
|
**Interfaces:**
|
|
- Consumes: MCP tool names from Task 2 and route changes from Task 1.
|
|
- Produces: user-facing docs and skill guidance that describe application tracking through MCP.
|
|
|
|
- [ ] **Step 1: Update the resume-builder skill**
|
|
|
|
In `skills/resume-builder/SKILL.md`, add after the Workflow section heading and before "Step 1":
|
|
|
|
```md
|
|
### MCP Application Tracking
|
|
|
|
Reactive Resume MCP can manage job applications as well as resumes. When the user wants to track applications through an agent, use the application tools instead of asking them to open the web UI.
|
|
|
|
- Use `list_applications` before changing existing records.
|
|
- Use `create_application` for one new opportunity or `import_applications` for spreadsheet/CSV rows.
|
|
- Use `update_application` to move stages, archive/unarchive, edit contacts, set follow-ups, link a resume, or update job details.
|
|
- Use `add_application_note` to log timeline activity.
|
|
- Use `attach_application_document` and `remove_application_document` for sent resume or cover-letter PDFs.
|
|
- Use `score_application_match`, `tailor_resume_for_application`, and `draft_application_message` for Application Copilot workflows after a linked resume and job description exist.
|
|
- Review AI-generated cover letters and follow-ups before sending them.
|
|
```
|
|
|
|
- [ ] **Step 2: Update the MCP server guide tool table**
|
|
|
|
In `docs/guides/using-the-mcp-server.mdx`, extend the Available Tools table with:
|
|
|
|
```md
|
|
| `list_applications` | List tracked job applications. Supports stage, tag, and archived filters |
|
|
| `read_application` | Read one full application record with contacts, follow-up details, documents, and timeline |
|
|
| `list_application_tags` | List every distinct tag used across applications |
|
|
| `get_application_stats` | Get aggregate application counts by stage and source |
|
|
| `create_application` | Create a tracked job application |
|
|
| `update_application` | Update fields, move stage, archive/unarchive, edit contacts/follow-ups/tags, or link a resume |
|
|
| `add_application_note` | Append a note to an application's activity timeline |
|
|
| `delete_application` | Permanently delete one application and its owned uploaded documents |
|
|
| `bulk_update_applications` | Move, archive/unarchive, or add tags to multiple applications |
|
|
| `bulk_delete_applications` | Permanently delete multiple applications |
|
|
| `import_applications` | Bulk-create parsed application rows, up to 500 items |
|
|
| `attach_application_document` | Attach a sent resume or cover-letter PDF from base64-encoded PDF bytes |
|
|
| `remove_application_document` | Remove a sent resume or cover-letter PDF |
|
|
| `autofill_application_from_job` | Use AI to extract job details from a URL or pasted job description |
|
|
| `score_application_match` | Score the linked resume against the application job description |
|
|
| `tailor_resume_for_application` | Create and link a tailored resume copy for an application |
|
|
| `draft_application_message` | Draft a cover letter or recruiter follow-up from application and resume context |
|
|
```
|
|
|
|
- [ ] **Step 3: Add application MCP usage examples**
|
|
|
|
In the Usage Examples section of `docs/guides/using-the-mcp-server.mdx`, add a subsection after Browsing:
|
|
|
|
```md
|
|
### Tracking Applications
|
|
|
|
- "Create an application for Senior Frontend Engineer at Acme, stage saved, source LinkedIn."
|
|
- "List my archived applications tagged remote."
|
|
- "Move my Acme application to interview and add a note that the technical screen is next Tuesday."
|
|
- "Attach this resume PDF to the Acme application."
|
|
- "Score the resume linked to this application against the job description."
|
|
- "Create a tailored resume copy for this application."
|
|
- "Draft a follow-up message for the recruiter."
|
|
```
|
|
|
|
Add a troubleshooting row:
|
|
|
|
```md
|
|
| "Application documents must be PDF files" | `attach_application_document` only accepts `contentType: "application/pdf"` and base64-encoded PDF bytes |
|
|
```
|
|
|
|
- [ ] **Step 4: Update the API/MCP automation use case**
|
|
|
|
Replace the first paragraph in `docs/use-cases/api-mcp-resume-automation.mdx` with:
|
|
|
|
```md
|
|
Reactive Resume supports automation through authenticated API access and an MCP server that lets compatible tools work with resumes and job applications. Agents can list, read, create, import, duplicate, and patch resumes, then track applications, move opportunities through stages, add notes and follow-ups, attach sent documents, and run Application Copilot actions.
|
|
```
|
|
|
|
Add to "Common automation workflows":
|
|
|
|
```md
|
|
- Track job applications end to end from an MCP client.
|
|
- Import existing application rows from a spreadsheet parser.
|
|
- Move applications through stages and log timeline notes.
|
|
- Attach the resume or cover-letter PDF sent for an application.
|
|
- Score a linked resume against a job description and create a tailored resume copy.
|
|
- Draft cover letters and recruiter follow-ups from saved application context.
|
|
```
|
|
|
|
- [ ] **Step 5: Refresh docs/spec.json**
|
|
|
|
Start the dev server in one terminal:
|
|
|
|
```bash
|
|
dotenvx run -f .env.local -- pnpm dev
|
|
```
|
|
|
|
In another terminal, after the server reports it is listening on port 3000, run:
|
|
|
|
```bash
|
|
curl -fsS http://localhost:3000/api/openapi/spec.json -o docs/spec.json
|
|
```
|
|
|
|
Expected: `docs/spec.json` contains `/applications` paths:
|
|
|
|
```bash
|
|
rg -n '"/applications|attachApplicationDocument|removeApplicationDocument' docs/spec.json
|
|
```
|
|
|
|
Expected: matches for the application paths and document operations.
|
|
|
|
- [ ] **Step 6: Run docs/skill checks**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
rg -n "list_applications|attach_application_document|tailor_resume_for_application" skills/resume-builder/SKILL.md docs/guides/using-the-mcp-server.mdx docs/use-cases/api-mcp-resume-automation.mdx
|
|
rg -n '"/applications|attachApplicationDocument|removeApplicationDocument' docs/spec.json
|
|
```
|
|
|
|
Expected: all commands print matching lines.
|
|
|
|
- [ ] **Step 7: Commit docs task**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
git add skills/resume-builder/SKILL.md docs/guides/using-the-mcp-server.mdx docs/use-cases/api-mcp-resume-automation.mdx docs/spec.json
|
|
git commit -m "docs: document application MCP automation"
|
|
```
|
|
|
|
Expected: commit succeeds.
|
|
|
|
---
|
|
|
|
### Task 5: Final Validation and Boundary Check
|
|
|
|
**Files:**
|
|
- No planned source edits.
|
|
|
|
**Interfaces:**
|
|
- Consumes: all previous task outputs.
|
|
- Produces: verified implementation ready for review.
|
|
|
|
- [ ] **Step 1: Run focused tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter @reactive-resume/api test -- src/dto/application.test.ts src/features/applications/service.test.ts
|
|
pnpm --filter @reactive-resume/mcp test -- src/tools.test.ts src/tool-annotations.test.ts src/mcp-server-card.test.ts
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 2: Run focused typechecks**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter @reactive-resume/api typecheck
|
|
pnpm --filter @reactive-resume/mcp typecheck
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 3: Run boundary check**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm exec turbo boundaries
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 4: Run non-mutating Biome check on touched files**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm exec biome check packages/api/src/dto/application.ts packages/api/src/dto/application.test.ts packages/api/src/features/applications/service.ts packages/api/src/features/applications/service.test.ts packages/api/src/features/applications/crud.ts packages/api/src/features/applications/router.ts packages/mcp/src/mcp-tool-names.ts packages/mcp/src/tool-meta.ts packages/mcp/src/tool-annotations.ts packages/mcp/src/tools.ts packages/mcp/src/tool-annotations.test.ts packages/mcp/src/mcp-server-card.test.ts packages/mcp/src/tools.test.ts skills/resume-builder/SKILL.md docs/guides/using-the-mcp-server.mdx docs/use-cases/api-mcp-resume-automation.mdx docs/spec.json
|
|
```
|
|
|
|
Expected: PASS. If Biome reports formatting fixes, run the same command with `--write`, inspect the diff, and commit only formatting changes.
|
|
|
|
- [ ] **Step 5: Inspect final diff**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
git status --short
|
|
git log --oneline -5
|
|
```
|
|
|
|
Expected: worktree is clean after the task commits, and recent commits are the REST/API, MCP metadata, MCP handler, and docs commits from this plan.
|