@servation/job-search-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js ADDED
@@ -0,0 +1,642 @@
1
+ /**
2
+ * Job-search MCP server: tool + UI resource wiring.
3
+ *
4
+ * find_jobs runs the ported deterministic scrapers (Phase 2) and returns live,
5
+ * deduped, UNSCORED jobs + the saved resume profile, rendering the inline review
6
+ * UI. The host model (Claude) is the evaluator: later it calls evaluate_jobs with
7
+ * extracted facts and the server scores via the ported computeMatchScore (Phase 4).
8
+ * No external LLM, no API key.
9
+ */
10
+ import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server";
11
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
+ import { z } from "zod";
13
+ import fs from "node:fs/promises";
14
+ import path from "node:path";
15
+ import { sourceJobs, yoeToLinkedInLevel } from "./pipeline.js";
16
+ import { readDb, writeDb } from "./db.js";
17
+ import { applyHolisticScore } from "./scoring.js";
18
+ const RESOURCE_URI = "ui://job-search/review.html";
19
+ // Per-job description cap in the wire payload (full text stays in the store). Keeps
20
+ // the tool result large enough for Claude to extract facts without flooding context.
21
+ const SLIM_DESC_CAP = 3000;
22
+ // Resolve the bundled UI HTML whether running from source (tsx) or compiled
23
+ // (dist/server.js). Mirrors Memora's DIST_DIR pattern.
24
+ const fromSource = import.meta.filename.endsWith(".ts");
25
+ const DIST_DIR = fromSource ? path.join(import.meta.dirname, "dist") : import.meta.dirname;
26
+ /** Structured-output shape the review UI reads from `structuredContent`. */
27
+ const JOB_OUT = {
28
+ id: z.string(),
29
+ title: z.string(),
30
+ company: z.string(),
31
+ location: z.string(),
32
+ url: z.string(),
33
+ description: z.string(),
34
+ postedAt: z.string(),
35
+ status: z.string(),
36
+ sourceTag: z.string().optional(),
37
+ salary: z.string().optional(),
38
+ isRemote: z.boolean().optional(),
39
+ matchScore: z.number(), // -1 until evaluated
40
+ matchReason: z.string().optional(),
41
+ experienceLevel: z.string().optional(),
42
+ skillsRequired: z.array(z.string()).optional(),
43
+ applicants: z.number().optional(),
44
+ };
45
+ const PROFILE_OUT = z
46
+ .object({
47
+ parsedName: z.string().optional(),
48
+ parsedSkills: z.array(z.string()).optional(),
49
+ targetRoles: z.array(z.string()).optional(),
50
+ searchLocation: z.string().optional(),
51
+ prefersRemote: z.boolean().optional(),
52
+ yearsOfExperience: z.number().optional(),
53
+ })
54
+ .nullable();
55
+ const FIND_JOBS_OUTPUT = {
56
+ jobs: z.array(z.object(JOB_OUT)),
57
+ count: z.number(),
58
+ scored: z.boolean(),
59
+ profile: PROFILE_OUT,
60
+ view: z.string().optional(), // "board" | "saved" — lets the UI re-fetch the right view on remount
61
+ };
62
+ /**
63
+ * Project a full Job to exactly the review-UI output shape. Full Job objects
64
+ * carry server-internal fields (type, isW2, scannedAt, ...) that are NOT in the
65
+ * tool's strict outputSchema; sending them would fail client-side validation
66
+ * (additionalProperties: false). Only this slim shape goes over the wire; the
67
+ * full Job lives in the store. Mirrors Memora's slimOf().
68
+ */
69
+ function slimJob(j) {
70
+ const out = {
71
+ // Defensive defaults: a malformed/old store entry must never emit `undefined`
72
+ // for a required field (that would fail the whole board's output validation).
73
+ id: j.id ?? "",
74
+ title: j.title ?? "Untitled role",
75
+ company: j.company ?? "Unknown",
76
+ location: j.location ?? "",
77
+ url: j.url ?? "",
78
+ description: (j.description ?? "").slice(0, SLIM_DESC_CAP),
79
+ postedAt: j.postedAt ?? j.scannedAt ?? "",
80
+ status: j.status ?? "discovered",
81
+ matchScore: typeof j.matchScore === "number" ? j.matchScore : -1,
82
+ };
83
+ if (j.sourceTag !== undefined)
84
+ out.sourceTag = j.sourceTag;
85
+ if (j.salary !== undefined)
86
+ out.salary = j.salary;
87
+ if (j.isRemote !== undefined)
88
+ out.isRemote = j.isRemote;
89
+ if (j.matchReason !== undefined)
90
+ out.matchReason = j.matchReason;
91
+ if (j.experienceLevel !== undefined)
92
+ out.experienceLevel = j.experienceLevel;
93
+ if (j.skillsRequired !== undefined)
94
+ out.skillsRequired = j.skillsRequired;
95
+ if (j.applicants !== undefined)
96
+ out.applicants = j.applicants;
97
+ return out;
98
+ }
99
+ /** Project the saved profile to the slim shape the UI header reads (or null). */
100
+ function slimProfile(profile) {
101
+ if (!profile)
102
+ return null;
103
+ const out = {};
104
+ if (profile.parsedName !== undefined)
105
+ out.parsedName = profile.parsedName;
106
+ if (profile.parsedSkills !== undefined)
107
+ out.parsedSkills = profile.parsedSkills;
108
+ if (profile.targetRoles !== undefined)
109
+ out.targetRoles = profile.targetRoles;
110
+ if (profile.searchLocation !== undefined)
111
+ out.searchLocation = profile.searchLocation;
112
+ if (profile.prefersRemote !== undefined)
113
+ out.prefersRemote = profile.prefersRemote;
114
+ if (profile.yearsOfExperience !== undefined)
115
+ out.yearsOfExperience = profile.yearsOfExperience;
116
+ return out;
117
+ }
118
+ /** Compact candidate summary the model scores jobs against (shown alongside the jobs). */
119
+ function profileSummary(profile) {
120
+ if (!profile)
121
+ return "Candidate: no saved profile — score against the criteria the user gave.";
122
+ const parts = [];
123
+ if (profile.parsedName)
124
+ parts.push(profile.parsedName);
125
+ if (typeof profile.yearsOfExperience === "number")
126
+ parts.push(`${profile.yearsOfExperience}y experience`);
127
+ if (profile.searchLocation)
128
+ parts.push(profile.searchLocation);
129
+ if (profile.targetRoles?.length)
130
+ parts.push(`target roles: ${profile.targetRoles.slice(0, 5).join(", ")}`);
131
+ if (profile.parsedSkills?.length)
132
+ parts.push(`skills: ${profile.parsedSkills.slice(0, 16).join(", ")}`);
133
+ return `Candidate — ${parts.join(" · ")}`;
134
+ }
135
+ // Per-job description excerpt included in TEXT content for evaluation. The model
136
+ // reads `content` text (not structuredContent), so the job id AND enough of the
137
+ // description to score it must live here.
138
+ const EVAL_EXCERPT = 1500;
139
+ /**
140
+ * One text block per job: the EXACT id (in brackets, for evaluate_jobs), a header,
141
+ * the URL, and a description excerpt. This is what lets Claude evaluate jobs.
142
+ */
143
+ function jobEvalBlocks(jobs) {
144
+ return jobs
145
+ .map((j) => `[${j.id}] ${j.title} — ${j.company} (${j.location})${j.salary ? ` · ${j.salary}` : ""}\n` +
146
+ `${j.url}\n` +
147
+ `${(j.description ?? "").replace(/\s+/g, " ").trim().slice(0, EVAL_EXCERPT)}`)
148
+ .join("\n\n");
149
+ }
150
+ /** Build the review-UI result from a set of jobs (ranked by score desc), with a text note. */
151
+ function boardResult(jobs, profile, note) {
152
+ const ranked = [...jobs].sort((a, b) => b.matchScore - a.matchScore);
153
+ const slim = ranked.map(slimJob);
154
+ return {
155
+ content: [{ type: "text", text: note }],
156
+ structuredContent: {
157
+ jobs: slim,
158
+ count: slim.length,
159
+ scored: ranked.some((j) => j.matchScore >= 0),
160
+ profile: slimProfile(profile),
161
+ view: "board",
162
+ },
163
+ };
164
+ }
165
+ /** Creates the job-search MCP server with the find_jobs tool and review UI. */
166
+ export function createServer() {
167
+ const server = new McpServer({ name: "Job Search MCP", version: "0.1.0" });
168
+ // find_jobs: run the deterministic scrapers (LinkedIn + 8 boards) and return live,
169
+ // deduped, UNSCORED jobs as TEXT (ids + full descriptions). This tool does NOT render
170
+ // a widget — the model immediately calls evaluate_jobs, which renders the single
171
+ // ranked board. Keeping find_jobs text-only avoids the find→render→re-render churn.
172
+ server.registerTool("find_jobs", {
173
+ title: "Find Jobs",
174
+ description: "Source live jobs (LinkedIn + Greenhouse, Lever, Ashby, Workday, SmartRecruiters, Hacker News, " +
175
+ "RemoteOK, Remotive), dedup them, and return them as text with full descriptions. Uses the saved " +
176
+ "profile's roles/location by default; pass query/location/filters to override. This returns UNSCORED " +
177
+ "jobs and does NOT show a card UI — immediately score all of them with evaluate_jobs (a 0-100 fit score " +
178
+ "+ reason each), then show_board once, to present the single ranked board. Do not just list them back.",
179
+ inputSchema: {
180
+ query: z.string().optional().describe("Role/keyword filter, e.g. 'backend java spring'. Overrides the profile's target roles."),
181
+ location: z.string().optional().describe("Location filter, e.g. 'California' or 'Remote'. Overrides the profile's search location."),
182
+ experience_level: z
183
+ .enum(["internship", "entry level", "associate", "senior", "director", "executive"])
184
+ .optional()
185
+ .describe("Seniority filter (LinkedIn)."),
186
+ job_type: z
187
+ .enum(["full time", "part time", "contract", "temporary", "internship", "volunteer"])
188
+ .optional()
189
+ .describe("Employment type (LinkedIn)."),
190
+ date_posted: z.enum(["24hr", "past week", "past month"]).optional().describe("Recency filter (LinkedIn)."),
191
+ remote: z.enum(["on site", "remote", "hybrid"]).optional().describe("Workplace type (LinkedIn); also sets remote preference."),
192
+ salary_min: z.number().optional().describe("Minimum annual salary filter (LinkedIn), e.g. 100000."),
193
+ max_applicants: z
194
+ .number()
195
+ .optional()
196
+ .describe("Surface low-applicant LinkedIn jobs (uses LinkedIn's early-applicant filter, ~under 25). Exact applicant-count filtering requires the LinkedIn cookie."),
197
+ max_years: z
198
+ .number()
199
+ .optional()
200
+ .describe("Candidate's years of experience for this search (overrides the saved profile); also defaults the seniority filter."),
201
+ sort_by: z.enum(["recent", "relevant"]).optional().describe("LinkedIn result ordering."),
202
+ sources: z
203
+ .array(z.enum(["linkedin", "greenhouse", "lever", "ashby", "workday", "smartrecruiters", "hackernews", "remoteok", "remotive"]))
204
+ .optional()
205
+ .describe("Restrict to these sources (default: all)."),
206
+ verify_urls: z.boolean().optional().describe("If true, network-verify each job URL (slower). Default false."),
207
+ limit: z.number().optional().describe("Max jobs to return/persist this run (default 15)."),
208
+ },
209
+ }, async ({ query, location, experience_level, job_type, date_posted, remote, salary_min, max_applicants, max_years, sort_by, sources, verify_urls, limit, }) => {
210
+ const criteria = {
211
+ query,
212
+ location,
213
+ verifyUrls: verify_urls,
214
+ limit,
215
+ experienceLevel: experience_level,
216
+ jobType: job_type,
217
+ datePosted: date_posted,
218
+ remote,
219
+ salaryMin: salary_min,
220
+ maxApplicants: max_applicants,
221
+ maxYears: max_years,
222
+ sortBy: sort_by,
223
+ sources,
224
+ };
225
+ let result;
226
+ try {
227
+ result = await sourceJobs(criteria);
228
+ }
229
+ catch (err) {
230
+ return { isError: true, content: [{ type: "text", text: `Sourcing failed: ${err?.message ?? String(err)}` }] };
231
+ }
232
+ const { jobs, profile, sourced, kept } = result;
233
+ const filters = [query && `query="${query}"`, location && `location="${location}"`].filter(Boolean).join(", ");
234
+ const filterNote = filters ? ` for ${filters}` : profile ? " using your saved profile" : "";
235
+ let text;
236
+ if (kept === 0) {
237
+ text =
238
+ `No NEW jobs found${filterNote} (fetched ${sourced} postings; all were filtered out or already seen). ` +
239
+ `Try a broader query or different filters — or call whats_promising to review/score jobs already on the board.`;
240
+ }
241
+ else {
242
+ text =
243
+ `${profileSummary(profile)}\n\n` +
244
+ `Found ${kept} unscored job(s)${filterNote} (from ${sourced} postings sourced). These are NOT shown as cards.\n\n` +
245
+ `Now SCORE each job 0-100 for this candidate (read its description; weigh must-haves, the candidate's skills ` +
246
+ `and years, seniority realism, domain fit) and call evaluate_jobs with one { job_id, score, reason } per job ` +
247
+ `for ALL of them using the EXACT bracketed ids below, then call show_board once to display the ranked board.\n\n` +
248
+ jobEvalBlocks(jobs);
249
+ }
250
+ return { content: [{ type: "text", text }] };
251
+ });
252
+ // evaluate_jobs: Claude submits extracted facts per job; the server scores each
253
+ // deterministically (computeMatchScore), persists, and re-renders the ranked board.
254
+ server.registerTool("evaluate_jobs", {
255
+ title: "Evaluate Jobs",
256
+ description: "Score one or more sourced jobs for THIS candidate (holistic). YOU assign each job a 0-100 fit score " +
257
+ "directly, using the candidate summary (shown in find_jobs / whats_promising) and the job description. " +
258
+ "Weigh the must-have requirements, the candidate's actual skills and years of experience (a role demanding " +
259
+ "far more seniority/years than the candidate has should score LOWER), domain fit, and standout strengths. " +
260
+ "Calibrate and use the FULL range — do not bunch everything at 80+: 80-100 = strong fit and realistic; " +
261
+ "60-79 = good with real gaps; 40-59 = partial/stretch (e.g. wrong level); below 40 = poor or wrong field. " +
262
+ "Pass { job_id, score, reason } per job. Use the EXACT bracketed ids; do not invent them. Score every job " +
263
+ "in one call. Returns text; call show_board afterward to display the ranked board.",
264
+ inputSchema: {
265
+ evaluations: z
266
+ .array(z.object({
267
+ job_id: z.string().describe("EXACT bracketed id from find_jobs/whats_promising."),
268
+ score: z.number().describe("Holistic 0-100 fit score for the candidate (calibrated; see the tool description)."),
269
+ reason: z.string().describe("One-line justification — what fits and what's the gap."),
270
+ experience_level: z.enum(["Junior", "Mid", "Senior", "Lead"]).optional().describe("The role's seniority level."),
271
+ industry: z.string().optional().describe("e.g. 'Technology', 'Finance'."),
272
+ salary_num: z.number().optional().describe("Numeric annual salary if stated."),
273
+ skills: z.array(z.string()).optional().describe("Key required skills (for display)."),
274
+ }))
275
+ .describe("One entry per job to score."),
276
+ },
277
+ }, async ({ evaluations }) => {
278
+ const db = readDb();
279
+ let scored = 0;
280
+ const notFound = [];
281
+ for (const ev of evaluations) {
282
+ const apply = (j) => applyHolisticScore(j, {
283
+ score: ev.score,
284
+ reason: ev.reason,
285
+ experienceLevel: ev.experience_level,
286
+ industry: ev.industry,
287
+ salaryNum: ev.salary_num,
288
+ skills: ev.skills,
289
+ });
290
+ const si = db.scannedJobs.findIndex((j) => j.id === ev.job_id);
291
+ if (si >= 0) {
292
+ db.scannedJobs[si] = apply(db.scannedJobs[si]);
293
+ scored++;
294
+ continue;
295
+ }
296
+ const vi = db.savedJobs.findIndex((j) => j.id === ev.job_id);
297
+ if (vi >= 0) {
298
+ db.savedJobs[vi] = apply(db.savedJobs[vi]);
299
+ scored++;
300
+ continue;
301
+ }
302
+ notFound.push(ev.job_id);
303
+ }
304
+ db.stats.evaluations += scored;
305
+ writeDb(db);
306
+ const ranked = [...db.scannedJobs].sort((a, b) => b.matchScore - a.matchScore);
307
+ const top = ranked
308
+ .filter((j) => j.matchScore >= 0)
309
+ .slice(0, 5)
310
+ .map((j, i) => `${i + 1}. ${j.matchScore} — ${j.title} @ ${j.company}${j.matchReason ? ` (${j.matchReason})` : ""}`)
311
+ .join("\n");
312
+ const note = `Scored ${scored} job(s).` +
313
+ (notFound.length ? ` ${notFound.length} id(s) not found (already triaged?).` : "") +
314
+ (top ? `\n\nTop matches:\n${top}` : "");
315
+ return { content: [{ type: "text", text: `${note}\n\nNow call show_board to display the ranked board.` }] };
316
+ });
317
+ // set_status: triage a job (called by the review UI's buttons, and usable by Claude).
318
+ registerAppTool(server, "set_status", {
319
+ title: "Set Job Status",
320
+ description: "Triage a job by id: 'saved' (interested/tracking), 'applied' (also stamps the date), 'dismissed' " +
321
+ "(skip), or 'discovered' (undo back to the board). Moves it between the scanned/saved/dismissed lists " +
322
+ "and persists. Optionally attach notes. Called by the review UI; also usable directly.",
323
+ inputSchema: {
324
+ job_id: z.string().describe("The job's id."),
325
+ status: z.enum(["saved", "applied", "dismissed", "discovered"]).describe("New triage status."),
326
+ notes: z.string().optional().describe("Optional notes to attach to the job."),
327
+ },
328
+ outputSchema: FIND_JOBS_OUTPUT,
329
+ _meta: { ui: { resourceUri: RESOURCE_URI } },
330
+ }, async ({ job_id, status, notes }) => {
331
+ const db = readDb();
332
+ const found = [...db.scannedJobs, ...db.savedJobs, ...db.dismissedJobs].find((j) => j.id === job_id);
333
+ if (!found) {
334
+ return { isError: true, content: [{ type: "text", text: `Job ${job_id} not found.` }] };
335
+ }
336
+ db.scannedJobs = db.scannedJobs.filter((j) => j.id !== job_id);
337
+ db.savedJobs = db.savedJobs.filter((j) => j.id !== job_id);
338
+ db.dismissedJobs = db.dismissedJobs.filter((j) => j.id !== job_id);
339
+ if (notes !== undefined)
340
+ found.notes = notes;
341
+ switch (status) {
342
+ case "applied":
343
+ found.status = "applied";
344
+ found.appliedDate = new Date().toISOString();
345
+ db.savedJobs.unshift(found);
346
+ break;
347
+ case "saved":
348
+ found.status = "review";
349
+ db.savedJobs.unshift(found);
350
+ break;
351
+ case "dismissed":
352
+ found.status = "dismissed";
353
+ db.dismissedJobs.unshift(found);
354
+ break;
355
+ case "discovered":
356
+ found.status = "discovered";
357
+ db.scannedJobs.unshift(found);
358
+ break;
359
+ }
360
+ writeDb(db);
361
+ return boardResult(db.scannedJobs, db.profile, `Marked "${found.title}" @ ${found.company} as ${status}.`);
362
+ });
363
+ // bulk_status: triage MANY board jobs at once by score/source (server does the selection,
364
+ // so the model doesn't need every job id). Solves "dismiss all roles under 60".
365
+ server.registerTool("bulk_status", {
366
+ title: "Bulk Triage",
367
+ description: "Triage MANY board jobs at once by score and/or source — e.g. dismiss every scored job under 60. " +
368
+ "Applies `status` to all scanned (board) jobs matching the filters; the server selects them, so you do " +
369
+ "NOT need the individual job ids. `below_score`/`above_score` match SCORED jobs only (use clear_jobs " +
370
+ "for un-evaluated ones). Provide at least one filter. Returns text; call show_board afterward to display.",
371
+ inputSchema: {
372
+ status: z.enum(["saved", "applied", "dismissed", "discovered"]).describe("Status to apply to every match."),
373
+ below_score: z.number().optional().describe("Match scored jobs with matchScore BELOW this (e.g. 60)."),
374
+ above_score: z.number().optional().describe("Match scored jobs with matchScore AT OR ABOVE this."),
375
+ source: z
376
+ .enum(["linkedin", "greenhouse", "lever", "ashby", "workday", "smartrecruiters", "hackernews", "remoteok", "remotive"])
377
+ .optional()
378
+ .describe("Restrict to one source."),
379
+ notes: z.string().optional().describe("Optional note to attach to every matched job."),
380
+ },
381
+ }, async ({ status, below_score, above_score, source, notes }) => {
382
+ if (below_score === undefined && above_score === undefined && source === undefined) {
383
+ return {
384
+ isError: true,
385
+ content: [
386
+ { type: "text", text: "Provide at least one filter (below_score, above_score, or source) so you don't triage the whole board by accident." },
387
+ ],
388
+ };
389
+ }
390
+ const db = readDb();
391
+ const hasScoreFilter = below_score !== undefined || above_score !== undefined;
392
+ const sel = db.scannedJobs.filter((j) => {
393
+ if (hasScoreFilter && j.matchScore < 0)
394
+ return false; // score filters apply to SCORED jobs only
395
+ if (below_score !== undefined && !(j.matchScore < below_score))
396
+ return false;
397
+ if (above_score !== undefined && !(j.matchScore >= above_score))
398
+ return false;
399
+ if (source !== undefined && j.sourceTag !== source)
400
+ return false;
401
+ return true;
402
+ });
403
+ const selIds = new Set(sel.map((j) => j.id));
404
+ db.scannedJobs = db.scannedJobs.filter((j) => !selIds.has(j.id));
405
+ const nowIso = new Date().toISOString();
406
+ for (const job of sel) {
407
+ if (notes !== undefined)
408
+ job.notes = notes;
409
+ if (status === "applied") {
410
+ job.status = "applied";
411
+ job.appliedDate = nowIso;
412
+ db.savedJobs.unshift(job);
413
+ }
414
+ else if (status === "saved") {
415
+ job.status = "review";
416
+ db.savedJobs.unshift(job);
417
+ }
418
+ else if (status === "dismissed") {
419
+ job.status = "dismissed";
420
+ db.dismissedJobs.unshift(job);
421
+ }
422
+ else {
423
+ job.status = "discovered";
424
+ db.scannedJobs.unshift(job);
425
+ }
426
+ }
427
+ writeDb(db);
428
+ const filterDesc = [
429
+ below_score !== undefined && `score<${below_score}`,
430
+ above_score !== undefined && `score>=${above_score}`,
431
+ source && `source=${source}`,
432
+ ]
433
+ .filter(Boolean)
434
+ .join(", ");
435
+ return {
436
+ content: [{ type: "text", text: `Set ${sel.length} job(s) (${filterDesc}) to ${status}. Call show_board to display the updated board.` }],
437
+ };
438
+ });
439
+ // whats_promising: show the current board ranked, and surface UNSCORED jobs (with
440
+ // ids + descriptions) so Claude can evaluate the backlog. Renders the review UI.
441
+ server.registerTool("whats_promising", {
442
+ title: "What's Promising",
443
+ description: "List the current job board as text: every scored job (with its id) and any UNSCORED jobs with their " +
444
+ "ids + descriptions so you can evaluate them. To score the unscored ones, call evaluate_jobs with the " +
445
+ "EXACT bracketed ids shown. Use this to review jobs already found (find_jobs only returns brand-new " +
446
+ "ones). To re-score jobs that ALREADY have a score, use rescore_board instead. To DISPLAY the board " +
447
+ "widget to the user, call show_board (this returns text for you to act on).",
448
+ inputSchema: {
449
+ limit: z.number().optional().describe("Max unscored jobs to surface for evaluation (default 15)."),
450
+ },
451
+ }, async ({ limit }) => {
452
+ const db = readDb();
453
+ const ranked = [...db.scannedJobs].sort((a, b) => b.matchScore - a.matchScore);
454
+ const scored = ranked.filter((j) => j.matchScore >= 0);
455
+ const unscored = ranked.filter((j) => j.matchScore < 0);
456
+ let text = `${profileSummary(db.profile)}\n\nBoard: ${scored.length} scored, ${unscored.length} unscored (${db.savedJobs.length} saved, ${db.dismissedJobs.length} dismissed).`;
457
+ if (scored.length) {
458
+ // List ALL scored jobs (with ids) so the model sees the full board in text — needed
459
+ // for reasoning like "dismiss everything under 60" (then use bulk_status).
460
+ text +=
461
+ `\n\nScored (${scored.length}), highest first — "score — title @ company [id]":\n` +
462
+ scored
463
+ .slice(0, 100)
464
+ .map((j) => `${j.matchScore} — ${j.title} @ ${j.company} [${j.id}]`)
465
+ .join("\n");
466
+ }
467
+ if (unscored.length) {
468
+ text +=
469
+ `\n\nUNSCORED — score each 0-100 for the candidate and call evaluate_jobs with one { job_id, score, reason } per job using the EXACT bracketed ids:\n\n` +
470
+ jobEvalBlocks(unscored.slice(0, limit ?? 15));
471
+ }
472
+ text += `\n\nTo show the board widget to the user, call show_board.`;
473
+ return { content: [{ type: "text", text }] };
474
+ });
475
+ // rescore_board: re-evaluate EVERY job already on the board (not just unscored). Maps
476
+ // directly to "rescore the board" — e.g. after the scoring approach changed.
477
+ server.registerTool("rescore_board", {
478
+ title: "Re-score Board",
479
+ description: "Re-score the WHOLE board from scratch — every job, INCLUDING ones that already have a score. Use this " +
480
+ "when the user asks to 'rescore'/'re-evaluate' the board or after the scoring approach changed. Returns " +
481
+ "all board jobs with their ids + descriptions; call evaluate_jobs with a FRESH { job_id, score, reason } " +
482
+ "for EACH (do not skip already-scored ones), then call show_board. Set include_saved=true to also re-score " +
483
+ "the saved/applied tracker.",
484
+ inputSchema: {
485
+ include_saved: z.boolean().optional().describe("Also re-score the saved/applied tracker jobs (default false)."),
486
+ },
487
+ }, async ({ include_saved }) => {
488
+ const db = readDb();
489
+ const jobs = include_saved ? [...db.scannedJobs, ...db.savedJobs] : db.scannedJobs;
490
+ if (!jobs.length) {
491
+ return { content: [{ type: "text", text: "No jobs on the board to re-score. Run find_jobs first." }] };
492
+ }
493
+ const text = `${profileSummary(db.profile)}\n\n` +
494
+ `RE-SCORE all ${jobs.length} job(s) below from scratch — assign a FRESH holistic 0-100 to each for this ` +
495
+ `candidate, ignoring any existing score. Call evaluate_jobs with one { job_id, score, reason } per job ` +
496
+ `using the EXACT bracketed ids, then call show_board.\n\n` +
497
+ jobEvalBlocks(jobs.slice(0, 80));
498
+ return { content: [{ type: "text", text }] };
499
+ });
500
+ // save_profile: Claude extracts the candidate profile from a pasted resume and
501
+ // saves it. The profile drives sourcing (roles/location) and scoring (yoe).
502
+ server.registerTool("save_profile", {
503
+ title: "Save Resume Profile",
504
+ description: "Extract the candidate's profile from their resume text (which the user pastes in chat) and save it. " +
505
+ "Pull: full name; core technical skills; suggested target roles; preferred/search location; total years " +
506
+ "of professional experience (integer); and pass the resume text as rawText. The profile is used by " +
507
+ "find_jobs (target roles + location) and by scoring (years of experience drives the experience penalty), " +
508
+ "so set yearsOfExperience accurately. Merges with any existing profile.",
509
+ inputSchema: {
510
+ name: z.string().optional().describe("Candidate's full name."),
511
+ skills: z.array(z.string()).optional().describe("Core technical skills."),
512
+ targetRoles: z.array(z.string()).optional().describe("Suggested target job titles."),
513
+ searchLocation: z.string().optional().describe("Preferred location, e.g. 'California' or 'Remote'."),
514
+ prefersRemote: z.boolean().optional().describe("Whether the candidate prefers remote."),
515
+ yearsOfExperience: z.number().optional().describe("Total years of professional experience (integer)."),
516
+ blockedCompanies: z.array(z.string()).optional().describe("Companies to exclude from sourcing."),
517
+ rawText: z.string().optional().describe("The full resume text, stored for reference."),
518
+ },
519
+ }, async ({ name, skills, targetRoles, searchLocation, prefersRemote, yearsOfExperience, blockedCompanies, rawText }) => {
520
+ const db = readDb();
521
+ const base = db.profile ?? { rawText: "", preferredTypes: ["Full-Time"], minMatchScore: 0, prefersRemote: true, prefersHybrid: false, searchLocation: "" };
522
+ const profile = {
523
+ ...base,
524
+ ...(rawText !== undefined ? { rawText } : {}),
525
+ ...(name !== undefined ? { parsedName: name } : {}),
526
+ ...(skills !== undefined ? { parsedSkills: skills } : {}),
527
+ ...(targetRoles !== undefined ? { targetRoles } : {}),
528
+ ...(searchLocation !== undefined ? { searchLocation, preferredLocation: searchLocation } : {}),
529
+ ...(prefersRemote !== undefined ? { prefersRemote } : {}),
530
+ ...(yearsOfExperience !== undefined ? { yearsOfExperience } : {}),
531
+ ...(blockedCompanies !== undefined ? { blockedCompanies } : {}),
532
+ };
533
+ db.profile = profile;
534
+ writeDb(db);
535
+ // Propose a ready-to-run starter search derived from the resume; these defaults also
536
+ // carry forward into bare find_jobs calls (via searchInputs).
537
+ const starter = { query: profile.targetRoles?.[0] || "Software Engineer" };
538
+ if (profile.searchLocation)
539
+ starter.location = profile.searchLocation;
540
+ const level = yoeToLinkedInLevel(profile.yearsOfExperience ?? 0);
541
+ if (level)
542
+ starter.experience_level = level;
543
+ if (profile.prefersRemote)
544
+ starter.remote = "remote";
545
+ const starterStr = Object.entries(starter)
546
+ .map(([k, v]) => `${k}="${v}"`)
547
+ .join(", ");
548
+ const text = `Saved profile${profile.parsedName ? ` for ${profile.parsedName}` : ""}: ` +
549
+ `${profile.targetRoles?.length ? profile.targetRoles.join(", ") : "(no roles)"} · ` +
550
+ `${profile.searchLocation || "(no location)"} · ${profile.yearsOfExperience ?? 0}y exp` +
551
+ `${profile.parsedSkills?.length ? ` · ${profile.parsedSkills.length} skills` : ""}.\n` +
552
+ `Scoring will use ${profile.yearsOfExperience ?? 0} years of experience, and find_jobs inherits these defaults.\n\n` +
553
+ `Suggested starter search — offer to run find_jobs with ${starterStr}, then evaluate the results.`;
554
+ return { content: [{ type: "text", text }] };
555
+ });
556
+ // review_saved: the tracker — jobs you've saved/applied to, with status + notes.
557
+ server.registerTool("review_saved", {
558
+ title: "Review Saved Jobs",
559
+ description: "List the tracker as text: jobs marked saved (interested) or applied, with their status, score, and " +
560
+ "notes. Use this to see what you're tracking or have applied to. To DISPLAY the tracker widget, call " +
561
+ "show_board with view='saved'.",
562
+ inputSchema: {},
563
+ }, async () => {
564
+ const db = readDb();
565
+ const rank = (s) => (s === "applied" ? 0 : s === "interviewing" ? 1 : s === "offered" ? 2 : 3);
566
+ const jobs = [...db.savedJobs].sort((a, b) => rank(a.status) - rank(b.status) || b.matchScore - a.matchScore);
567
+ const applied = jobs.filter((j) => j.status === "applied").length;
568
+ const text = jobs.length
569
+ ? `Tracking ${jobs.length} job(s) (${applied} applied):\n` +
570
+ jobs
571
+ .map((j) => `- [${j.status}] ${j.title} @ ${j.company}${j.matchScore >= 0 ? ` (${j.matchScore})` : ""}${j.notes ? ` — ${j.notes}` : ""}`)
572
+ .join("\n")
573
+ : "No saved jobs yet. Mark jobs as 'saved' or 'applied' (triage buttons or set_status) to track them here.";
574
+ return { content: [{ type: "text", text: `${text}\n\nTo display the tracker, call show_board with view="saved".` }] };
575
+ });
576
+ // clear_jobs: declutter the review board. Removes scanned jobs only; never touches
577
+ // saved/applied (the tracker) or dismissed (so dismissed jobs still won't be re-sourced).
578
+ server.registerTool("clear_jobs", {
579
+ title: "Clear Jobs",
580
+ description: "Clear jobs from the review board. which='unscored' (default) removes only the not-yet-evaluated " +
581
+ "leftovers; which='all' clears the whole board. Saved/applied jobs (the tracker) and dismissed jobs " +
582
+ "are NOT affected — dismissed jobs still won't be re-sourced. Returns text; call show_board to display.",
583
+ inputSchema: {
584
+ which: z
585
+ .enum(["unscored", "all"])
586
+ .optional()
587
+ .describe("'unscored' (default) clears un-evaluated jobs; 'all' clears the whole board."),
588
+ },
589
+ }, async ({ which }) => {
590
+ const db = readDb();
591
+ const before = db.scannedJobs.length;
592
+ const mode = which ?? "unscored";
593
+ db.scannedJobs = mode === "all" ? [] : db.scannedJobs.filter((j) => j.matchScore >= 0);
594
+ const removed = before - db.scannedJobs.length;
595
+ writeDb(db);
596
+ const note = `Cleared ${removed} ${mode === "all" ? "" : "unscored "}job(s) from the board; ${db.scannedJobs.length} remain. ` +
597
+ `Saved/applied and dismissed jobs were untouched.`;
598
+ return { content: [{ type: "text", text: `${note} Call show_board to display the board.` }] };
599
+ });
600
+ // show_board: the ONE tool that renders the widget. Call it once at the end of a request
601
+ // (after find/evaluate/triage/clear) to display the result — keeps multi-step requests to a
602
+ // single widget instead of one per tool call.
603
+ registerAppTool(server, "show_board", {
604
+ title: "Show Board",
605
+ description: "Display the job board (or the saved/applied tracker) as an interactive widget. Call this ONCE, at the " +
606
+ "end of a request, after doing the work (find_jobs/evaluate_jobs/bulk_status/clear_jobs/whats_promising " +
607
+ "are text-only and do NOT render). view='board' (default) shows the ranked board; view='saved' shows the " +
608
+ "tracker.",
609
+ inputSchema: {
610
+ view: z.enum(["board", "saved"]).optional().describe("'board' (default) = the ranked board; 'saved' = the applied/saved tracker."),
611
+ },
612
+ outputSchema: FIND_JOBS_OUTPUT,
613
+ _meta: { ui: { resourceUri: RESOURCE_URI } },
614
+ }, async ({ view }) => {
615
+ const db = readDb();
616
+ if (view === "saved") {
617
+ const rank = (s) => (s === "applied" ? 0 : s === "interviewing" ? 1 : s === "offered" ? 2 : 3);
618
+ const jobs = [...db.savedJobs].sort((a, b) => rank(a.status) - rank(b.status) || b.matchScore - a.matchScore);
619
+ return {
620
+ content: [{ type: "text", text: jobs.length ? `Showing ${jobs.length} tracked job(s).` : "No saved/applied jobs yet." }],
621
+ structuredContent: {
622
+ jobs: jobs.map(slimJob),
623
+ count: jobs.length,
624
+ scored: jobs.some((j) => j.matchScore >= 0),
625
+ profile: slimProfile(db.profile),
626
+ view: "saved",
627
+ },
628
+ };
629
+ }
630
+ const note = db.scannedJobs.length ? `Showing the board (${db.scannedJobs.length} jobs).` : "The board is empty — run find_jobs.";
631
+ return boardResult(db.scannedJobs, db.profile, note);
632
+ });
633
+ // The bundled review UI the host renders in a sandboxed iframe.
634
+ registerAppResource(server, "Job Search Review", RESOURCE_URI, { mimeType: RESOURCE_MIME_TYPE }, async () => {
635
+ const html = await fs.readFile(path.join(DIST_DIR, "mcp-app.html"), "utf-8");
636
+ return {
637
+ contents: [{ uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html }],
638
+ };
639
+ });
640
+ return server;
641
+ }
642
+ //# sourceMappingURL=server.js.map