@juicedresume/mcp 0.3.2 → 0.4.1

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.
Files changed (2) hide show
  1. package/dist/index.js +237 -72
  2. package/package.json +2 -1
package/dist/index.js CHANGED
@@ -220,6 +220,7 @@ var MULTI_COLUMN_TEMPLATES = [
220
220
  "coral",
221
221
  "prism",
222
222
  "deedy",
223
+ "meridian",
223
224
  "nova",
224
225
  "axis",
225
226
  "kintsugi",
@@ -238,6 +239,14 @@ var Styling = z.object({
238
239
  accent: z.string().default("#111111"),
239
240
  fontHeading: z.string().default("Playfair Display"),
240
241
  fontBody: z.string().default("Inter"),
242
+ // Per-resume font OVERRIDES. Empty string = "use the template's designed
243
+ // font" (the default look). When set, the chosen family wins across every
244
+ // template — including Classic/Sidebar templates that otherwise hard-code
245
+ // their own faces — and is applied identically in the editor preview and the
246
+ // exported PDF. Kept separate from fontHeading/fontBody so existing resumes
247
+ // (which never set these) render exactly as before.
248
+ fontHeadingOverride: z.string().default(""),
249
+ fontBodyOverride: z.string().default(""),
241
250
  fontSize: z.number().default(10.5),
242
251
  // body pt
243
252
  lineHeight: z.number().default(1.5),
@@ -268,7 +277,7 @@ var Locale = z.object({
268
277
  dateFormat: z.enum(["MM/YYYY", "MMM YYYY", "YYYY", "DD/MM/YYYY", "MM/DD/YYYY"]).default("MMM YYYY"),
269
278
  pageFormat: z.enum(["A4", "Letter"]).default("A4")
270
279
  });
271
- var Resume = z.object({
280
+ var ResumeObject = z.object({
272
281
  id: z.string(),
273
282
  name: z.string().default("Untitled"),
274
283
  schemaVersion: z.literal(2).default(2),
@@ -279,6 +288,29 @@ var Resume = z.object({
279
288
  styling: Styling,
280
289
  locale: Locale
281
290
  });
291
+ var RICH_TEXT_KEYS = /* @__PURE__ */ new Set(["body", "description"]);
292
+ var URL_KEYS = /* @__PURE__ */ new Set(["photoUrl", "url", "link", "employerLink", "schoolLink", "website", "linkedin", "github", "twitter"]);
293
+ var LONG_KEYS = /* @__PURE__ */ new Set(["skills"]);
294
+ function capFor(key) {
295
+ if (RICH_TEXT_KEYS.has(key)) return 6e4;
296
+ if (LONG_KEYS.has(key)) return 1e4;
297
+ if (URL_KEYS.has(key)) return 2e3;
298
+ return 1e3;
299
+ }
300
+ function capStrings(value, key = "") {
301
+ if (typeof value === "string") {
302
+ const max = capFor(key);
303
+ return value.length > max ? value.slice(0, max) : value;
304
+ }
305
+ if (Array.isArray(value)) return value.map((v) => capStrings(v, key));
306
+ if (value && typeof value === "object") {
307
+ const out = {};
308
+ for (const [k, v] of Object.entries(value)) out[k] = capStrings(v, k);
309
+ return out;
310
+ }
311
+ return value;
312
+ }
313
+ var Resume = z.preprocess((value) => capStrings(value), ResumeObject);
282
314
  var CoverLetter = z.object({
283
315
  id: z.string(),
284
316
  name: z.string().default("Untitled"),
@@ -1415,15 +1447,29 @@ function bigrams(toks) {
1415
1447
  return out;
1416
1448
  }
1417
1449
  function resumeText(resume) {
1418
- const parts = [];
1419
- parts.push(resume.personal.title);
1450
+ const parts = [resume.personal.title];
1420
1451
  for (const sec of resume.sections) {
1452
+ if (sec.visible === false) continue;
1421
1453
  for (const it of sec.items) {
1422
- parts.push(it.body || "");
1423
- parts.push(it.description || "");
1424
- parts.push(it.skills || "");
1425
- parts.push(it.jobTitle || it.degree || it.name || it.title || "");
1426
- parts.push(it.employer || it.school || it.publisher || it.institution || "");
1454
+ if (it.visible === false) continue;
1455
+ parts.push(
1456
+ it.body || "",
1457
+ it.description || "",
1458
+ it.skills || "",
1459
+ it.category || "",
1460
+ it.jobTitle || "",
1461
+ it.degree || "",
1462
+ it.field || "",
1463
+ it.name || "",
1464
+ it.title || "",
1465
+ it.role || "",
1466
+ it.employer || "",
1467
+ it.school || "",
1468
+ it.publisher || "",
1469
+ it.institution || "",
1470
+ it.issuer || "",
1471
+ it.location || ""
1472
+ );
1427
1473
  }
1428
1474
  }
1429
1475
  return parts.join(" ").replace(/<[^>]+>/g, " ");
@@ -1433,16 +1479,16 @@ function tailorToJob(resume, jdText) {
1433
1479
  const resumeFullText = resumeText(resume).toLowerCase();
1434
1480
  const resumeToks = new Set(tokens(resumeFullText));
1435
1481
  const resumeNorm = new Set([...resumeToks].map((t) => normalizeSkill(t).toLowerCase()));
1436
- function resumeHas(keyword) {
1482
+ function matchKind(keyword) {
1437
1483
  const k = keyword.toLowerCase();
1438
1484
  if (k.includes(" ")) {
1439
- return new RegExp(`\\b${escapeRe(k)}\\b`).test(resumeFullText);
1485
+ return new RegExp(`\\b${escapeRe(k)}\\b`).test(resumeFullText) ? "exact" : "none";
1440
1486
  }
1441
- if (resumeToks.has(k)) return true;
1487
+ if (resumeToks.has(k)) return "exact";
1442
1488
  const canon = normalizeSkill(keyword).toLowerCase();
1443
- if (resumeNorm.has(canon)) return true;
1444
- if (canon.includes(" ") && new RegExp(`\\b${escapeRe(canon)}\\b`).test(resumeFullText)) return true;
1445
- return false;
1489
+ if (resumeNorm.has(canon)) return "variant";
1490
+ if (canon.includes(" ") && new RegExp(`\\b${escapeRe(canon)}\\b`).test(resumeFullText)) return "variant";
1491
+ return "none";
1446
1492
  }
1447
1493
  const jdGrams = /* @__PURE__ */ new Set([...jdToks, ...bigrams(jdToks)]);
1448
1494
  const skillNames = new Map(SKILLS.map((s) => [s.name.toLowerCase(), s]));
@@ -1460,10 +1506,20 @@ function tailorToJob(resume, jdText) {
1460
1506
  const matched = [];
1461
1507
  const missing = [];
1462
1508
  const kindMult = (kind) => kind === "hard" ? 2 : kind === "soft" ? 1.3 : 1;
1509
+ const VARIANT_CREDIT = 0.6;
1463
1510
  for (const [k, meta] of jdSkills) {
1464
1511
  const w = meta.importance * kindMult(meta.kind);
1465
- if (resumeHas(k)) {
1512
+ const kind = matchKind(k);
1513
+ if (kind === "exact") {
1466
1514
  matched.push({ keyword: k, kind: meta.kind, count: w });
1515
+ } else if (kind === "variant") {
1516
+ matched.push({
1517
+ keyword: k,
1518
+ kind: meta.kind,
1519
+ count: w * VARIANT_CREDIT,
1520
+ partial: true,
1521
+ note: `You use a variant of "${k}". ATS keyword search is usually exact-match, so add the exact term "${k}" too.`
1522
+ });
1467
1523
  } else {
1468
1524
  missing.push({ keyword: k, kind: meta.kind, importance: w });
1469
1525
  }
@@ -1483,9 +1539,71 @@ function tailorToJob(resume, jdText) {
1483
1539
  missing,
1484
1540
  jdSkills: jdSkills.size,
1485
1541
  resumeSkills: resumeToks.size,
1486
- titleMatch
1542
+ titleMatch,
1543
+ flags: jdKnockoutFlags(resume, jdText)
1487
1544
  };
1488
1545
  }
1546
+ function tailorMMYYYY(d) {
1547
+ if (!d) return null;
1548
+ const m = d.match(/^(\d{1,2})\/(\d{4})$/);
1549
+ if (m) return parseInt(m[2], 10) * 12 + parseInt(m[1], 10);
1550
+ const mn = d.match(/^([A-Za-z]+)\s+(\d{4})$/);
1551
+ if (mn) {
1552
+ const months = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
1553
+ const i = months.findIndex((x) => mn[1].toLowerCase().startsWith(x));
1554
+ if (i >= 0) return parseInt(mn[2], 10) * 12 + (i + 1);
1555
+ }
1556
+ const y = d.match(/^(\d{4})$/);
1557
+ if (y) return parseInt(y[1], 10) * 12;
1558
+ return null;
1559
+ }
1560
+ function resumeYears(resume) {
1561
+ const exp = resume.sections.find((s) => s.type === "experience");
1562
+ if (!exp) return 0;
1563
+ const now = (/* @__PURE__ */ new Date()).getFullYear() * 12 + ((/* @__PURE__ */ new Date()).getMonth() + 1);
1564
+ let months = 0;
1565
+ for (const it of exp.items || []) {
1566
+ if (it.visible === false) continue;
1567
+ const s = tailorMMYYYY(it.startDate || "");
1568
+ const e = it.current ? now : tailorMMYYYY(it.endDate || "");
1569
+ if (s && e && e >= s) months += e - s;
1570
+ }
1571
+ return Math.round(months / 12 * 10) / 10;
1572
+ }
1573
+ function jdKnockoutFlags(resume, jdText) {
1574
+ const flags = [];
1575
+ const degreeRequired = /\b(bachelor|master|b\.?s\.?|m\.?s\.?|b\.?a\.?|ph\.?d|mba|degree)\b[^.\n]{0,40}\b(required|require|must|minimum|mandatory)\b/i.test(jdText) || /\b(required|must have|minimum|mandatory)\b[^.\n]{0,40}\b(bachelor|master|degree|b\.?s\.?|m\.?s\.?|ph\.?d|diploma)\b/i.test(jdText);
1576
+ if (degreeRequired) {
1577
+ const eduSec = resume.sections.find((s) => s.type === "education");
1578
+ const hasDegree = (eduSec?.items || []).some((it) => it.visible !== false && String(it.degree || "").trim());
1579
+ if (!hasDegree) {
1580
+ flags.push({
1581
+ id: "K4.missing-required-degree",
1582
+ severity: "risk",
1583
+ title: "This job states a degree requirement",
1584
+ detail: "The description asks for a specific degree, and no completed degree is listed on your resume. A stated degree requirement is often a hard knockout filter.",
1585
+ suggestion: "Add your degree in the Education section, or if you have equivalent experience, be ready to address the gap in a cover note.",
1586
+ requirement: "degree"
1587
+ });
1588
+ }
1589
+ }
1590
+ const yoe = [...jdText.matchAll(/(\d{1,2})\s*\+?\s*(?:to|-|–|—)?\s*\d{0,2}\s*years?(?:['’]?s?)?\s*(?:of\s+)?(?:experience|exp\b)/gi)].map((m) => parseInt(m[1], 10)).filter((n) => n >= 1 && n <= 20);
1591
+ if (yoe.length) {
1592
+ const required = Math.min(...yoe);
1593
+ const have = resumeYears(resume);
1594
+ if (have + 0.5 < required) {
1595
+ flags.push({
1596
+ id: "K5.yoe-below-requirement",
1597
+ severity: "risk",
1598
+ title: `Asks for ${required}+ years; your resume shows about ${have}`,
1599
+ detail: "Years of experience is one of the most common automated and recruiter filters. A year or more below the stated minimum is a frequent screen-out.",
1600
+ suggestion: "Make sure every relevant role has dates so your full tenure is counted, and include earlier or concurrent experience if it applies.",
1601
+ requirement: `${required}+ years`
1602
+ });
1603
+ }
1604
+ }
1605
+ return flags;
1606
+ }
1489
1607
 
1490
1608
  // ../../packages/scoring/src/v2-additions.ts
1491
1609
  function stripHtml(s) {
@@ -1531,41 +1649,6 @@ function firstBulletStrongest(resume) {
1531
1649
  )
1532
1650
  };
1533
1651
  }
1534
- function dateFormatConsistency(resume) {
1535
- const exp = resume.sections.find((s) => s.type === "experience");
1536
- const edu = resume.sections.find((s) => s.type === "education");
1537
- const dates = [];
1538
- [...exp?.items || [], ...edu?.items || []].forEach((it) => {
1539
- if (it.startDate) dates.push(it.startDate);
1540
- if (it.endDate && !it.current) dates.push(it.endDate);
1541
- });
1542
- if (dates.length < 2) return null;
1543
- const MM_YYYY = /^\d{1,2}\/\d{4}$/;
1544
- const MMM_YYYY = /^[A-Za-z]+\s+\d{4}$/;
1545
- const YYYY = /^\d{4}$/;
1546
- const ABBREV = /['']\d{2}|\d{1,2}\/\d{2}|\b[A-Z][a-z]{2}'\d{2}\b/;
1547
- const formats = /* @__PURE__ */ new Set();
1548
- for (const d of dates) {
1549
- if (ABBREV.test(d)) formats.add("abbrev");
1550
- else if (MM_YYYY.test(d)) formats.add("mm/yyyy");
1551
- else if (MMM_YYYY.test(d)) formats.add("month-yyyy");
1552
- else if (YYYY.test(d)) formats.add("yyyy");
1553
- else formats.add("other");
1554
- }
1555
- const hasAbbrev = formats.has("abbrev");
1556
- const inconsistent = formats.size > 1;
1557
- const score = hasAbbrev ? 0.2 : inconsistent ? 0.55 : 1;
1558
- return {
1559
- dim: "style",
1560
- check: passSoft(
1561
- "C9.date-format",
1562
- "Date format is consistent",
1563
- score,
1564
- hasAbbrev ? "Date abbreviations like '21 or Jan'24 break ATS years-of-experience calculation." : inconsistent ? `You're mixing date formats (${[...formats].join(", ")}). Pick one and apply consistently.` : "Dates are formatted consistently \u2014 ATS parses them cleanly.",
1565
- "Use MM/YYYY (06/2024) or Month YYYY (June 2024) \u2014 consistently across every entry."
1566
- )
1567
- };
1568
- }
1569
1652
  function targetTitle(resume) {
1570
1653
  const title = resume.personal?.title?.trim() || "";
1571
1654
  const score = title.length >= 3 ? 1 : 0;
@@ -1626,7 +1709,6 @@ function threeLocationsRule(resume) {
1626
1709
  function additionalChecks(resume) {
1627
1710
  return [
1628
1711
  firstBulletStrongest(resume),
1629
- dateFormatConsistency(resume),
1630
1712
  targetTitle(resume),
1631
1713
  dobAgePenalty(resume),
1632
1714
  threeLocationsRule(resume)
@@ -2642,7 +2724,8 @@ function atsChecks(resume, b) {
2642
2724
  const allText = b.bullets.map((x) => x.text).join(" ");
2643
2725
  const isSingleCol = !isMultiColumnTemplate(s.template);
2644
2726
  const isSafeFont = SAFE_FONTS.has(s.fontHeading) && SAFE_FONTS.has(s.fontBody);
2645
- const fontSizeOK = s.fontSize >= 9.5 && s.fontSize <= 12;
2727
+ const fontSizeIdeal = s.fontSize >= 10 && s.fontSize <= 12;
2728
+ const fontSizeAcceptable = s.fontSize >= 9.5 && s.fontSize <= 12.5;
2646
2729
  const safeBullet = SAFE_BULLET_GLYPHS.has(s.bulletGlyph === "disc" ? "\u2022" : s.bulletGlyph === "dash" ? "-" : "");
2647
2730
  const emailOK = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(p.email);
2648
2731
  const phoneOK = !p.phone || /(\+?\d[\d\s().-]{6,})/.test(p.phone);
@@ -2684,10 +2767,14 @@ function atsChecks(resume, b) {
2684
2767
  // single-column), but ~1 in 10 still scrambles reading order and columns
2685
2768
  // cost human skimmability. A single, evidence-scaled ding — the overall
2686
2769
  // score also takes a small proportional multi-column haircut (see below).
2687
- isSingleCol ? 1 : 0.55,
2688
- isSingleCol ? "Single-column layout \u2014 every ATS parses this cleanly." : "Multi-column template. Modern parsers usually handle columns, but a minority still scramble reading order, and columns are harder for a recruiter to skim. Single-column is the zero-risk choice.",
2770
+ // ~0.85 reflects the real ~90% correct column-extraction rate reported by
2771
+ // Textkernel, so the within-dimension ding stays light; the small overall
2772
+ // multi-column haircut in scoreResume carries the actual score effect
2773
+ // (we deliberately don't double-penalize with a 0.55 here).
2774
+ isSingleCol ? 1 : 0.85,
2775
+ isSingleCol ? "Single-column layout. Every ATS parses this cleanly." : "Multi-column template. Modern parsers handle columns about 90% of the time, but a minority scramble reading order, and columns are harder for a recruiter to skim.",
2689
2776
  [],
2690
- !isSingleCol ? "For maximum ATS safety, switch to a single-column template (Jake, Harvard, Classic) in the Templates tab. Not required, but it removes all parse risk." : void 0,
2777
+ !isSingleCol ? "Exporting via the ATS PDF button always outputs a single-column, parse-clean file, so you can keep this template on screen and still submit a safe PDF. Or switch to a single-column template (Jake, Harvard, Classic) for a single source." : void 0,
2691
2778
  "soft"
2692
2779
  ),
2693
2780
  mk(
@@ -2776,10 +2863,10 @@ function atsChecks(resume, b) {
2776
2863
  mk(
2777
2864
  "E11.font-size",
2778
2865
  "Body font size 10\u201312pt",
2779
- fontSizeOK ? 1 : 0.5,
2780
- fontSizeOK ? `Font size ${s.fontSize}pt is in range.` : `Font size ${s.fontSize}pt is outside the 10\u201312pt range.`,
2866
+ fontSizeIdeal ? 1 : fontSizeAcceptable ? 0.75 : 0.5,
2867
+ fontSizeIdeal ? `Font size ${s.fontSize}pt is in the ideal 10\u201312pt band.` : fontSizeAcceptable ? `Font size ${s.fontSize}pt is readable but outside the ideal 10\u201312pt band.` : `Font size ${s.fontSize}pt is outside the readable 9.5\u201312.5pt range.`,
2781
2868
  [],
2782
- !fontSizeOK ? "Set body size to 10\u201312pt." : void 0,
2869
+ !fontSizeIdeal ? "Set body size to 10\u201312pt for the best balance of density and legibility." : void 0,
2783
2870
  void 0,
2784
2871
  0.5
2785
2872
  ),
@@ -2841,7 +2928,7 @@ function skillsChecks(resume, b) {
2841
2928
  stuffed.length === 0 ? 1 : 1 - Math.min(1, stuffed.length / 3),
2842
2929
  stuffed.length === 0 ? "No token over-repeated." : `Over-repeated: ${stuffed.slice(0, 4).map(([k, v]) => `${k}\xD7${v}`).join(", ")}.`,
2843
2930
  [],
2844
- stuffed.length ? "Diversify language \u2014 repeating a keyword more than 5\xD7 looks like stuffing." : void 0
2931
+ stuffed.length ? "Diversify your language. Repeating the same keyword more than 8 times reads as stuffing, and frequency past the first mention does not help ATS ranking anyway." : void 0
2845
2932
  ),
2846
2933
  mk(
2847
2934
  "F5.acronym-expansion",
@@ -2928,7 +3015,9 @@ function polishChecks(resume, b) {
2928
3015
  hasHiddenText ? "Hidden or invisible text (white-on-white, zero-size) or an AI-instruction string was detected. ATS convert your file to plain text, so these become visible and read as gaming the system." : "No hidden text or prompt-injection tricks detected.",
2929
3016
  [],
2930
3017
  hasHiddenText ? "Remove any white/invisible text, zero-size fonts, or 'rate me highly'-style instructions. They backfire once the ATS strips formatting." : void 0,
2931
- void 0,
3018
+ // Hard severity: hidden text / prompt-injection is the one trick that
3019
+ // genuinely backfires, so a positive detection caps the overall score.
3020
+ "hard",
2932
3021
  2
2933
3022
  )
2934
3023
  ];
@@ -2972,6 +3061,52 @@ function experienceYears(resume) {
2972
3061
  }
2973
3062
  return Math.floor(months / 12);
2974
3063
  }
3064
+ function computeFlags(resume) {
3065
+ const flags = [];
3066
+ const p = resume.personal;
3067
+ const emailOK = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(p.email);
3068
+ const hasPhone = !!(p.phone && /(\+?\d[\d\s().-]{6,})/.test(p.phone));
3069
+ const nameOK = !!(p.fullName && p.fullName.trim().length >= 2);
3070
+ if (!(nameOK && (emailOK || hasPhone))) {
3071
+ flags.push({
3072
+ id: "K1.contact-unparseable",
3073
+ severity: "block",
3074
+ title: "Contact details can't be parsed",
3075
+ detail: !nameOK ? "No full name is set. ATS build the candidate record from a name plus an email or phone, so without a name the whole resume can be dropped." : "No valid email or phone is set. Most parsers reject a record that has neither.",
3076
+ suggestion: "Add your full name and at least one of a valid email or phone."
3077
+ });
3078
+ }
3079
+ const expSec = resume.sections.find((s) => s.type === "experience");
3080
+ const expItems = (expSec?.items || []).filter((it) => it.visible !== false);
3081
+ const datelessRoles = expItems.filter((it) => !parseMMYYYY(it.startDate || ""));
3082
+ if (expItems.length > 0 && datelessRoles.length > 0) {
3083
+ flags.push({
3084
+ id: "K2.role-without-dates",
3085
+ severity: "risk",
3086
+ title: `${datelessRoles.length} role${datelessRoles.length === 1 ? "" : "s"} missing dates`,
3087
+ detail: "A role with no start date can't be ordered or counted toward years of experience by a parser, and years-of-experience filters are a top recruiter screen.",
3088
+ suggestion: "Add a start and end date (MM/YYYY or Month YYYY) to every role."
3089
+ });
3090
+ }
3091
+ const nowMonths = (/* @__PURE__ */ new Date()).getFullYear() * 12 + ((/* @__PURE__ */ new Date()).getMonth() + 1);
3092
+ const ranges = expItems.map((it) => ({ start: parseMMYYYY(it.startDate || ""), end: it.current ? nowMonths : parseMMYYYY(it.endDate || it.startDate || "") })).filter((r) => r.start && r.end);
3093
+ ranges.sort((a, b) => b.start - a.start);
3094
+ let maxGap = 0;
3095
+ for (let i = 0; i < ranges.length - 1; i++) {
3096
+ const gap = ranges[i].start - ranges[i + 1].end;
3097
+ if (gap > maxGap) maxGap = gap;
3098
+ }
3099
+ if (maxGap > 6) {
3100
+ flags.push({
3101
+ id: "K3.employment-gap",
3102
+ severity: "risk",
3103
+ title: `About a ${maxGap}-month employment gap`,
3104
+ detail: "Some employers screen for gaps over 6 months. A gap is legitimate, but an unexplained one lowers callback odds.",
3105
+ suggestion: "Add a one-line note (contract, study, caregiving) or a short bridging entry so the gap does not read as unexplained."
3106
+ });
3107
+ }
3108
+ return flags;
3109
+ }
2975
3110
  function aggregate(checks, weight) {
2976
3111
  if (!checks.length) return { score: 0, weighted: 0 };
2977
3112
  let wsum = 0, wtot = 0;
@@ -3001,7 +3136,7 @@ var LABELS = {
3001
3136
  skills: "Skills & Keywords",
3002
3137
  polish: "Completeness & Polish"
3003
3138
  };
3004
- function scoreResume(resume) {
3139
+ function scoreResume(resume, opts) {
3005
3140
  const b = gather(resume);
3006
3141
  const dims = [
3007
3142
  { dim: "impact", checks: impactChecks(resume, b) },
@@ -3036,6 +3171,21 @@ function scoreResume(resume) {
3036
3171
  }
3037
3172
  const hardFail = dimensions.some((d) => d.checks.some((c) => c.severity === "hard" && c.status === "fail"));
3038
3173
  if (hardFail) overall = Math.min(overall, 60);
3174
+ const parseFindings = opts?.parseFindings ?? [];
3175
+ const parseHard = parseFindings.some((f) => f.severity === "hard");
3176
+ const parseSoftCount = parseFindings.filter((f) => f.severity === "soft").length;
3177
+ if (parseHard) overall = Math.min(overall, 55);
3178
+ if (parseSoftCount) overall = Math.max(0, overall - Math.min(12, parseSoftCount * 4));
3179
+ const flags = [
3180
+ ...computeFlags(resume),
3181
+ ...parseFindings.map((f) => ({
3182
+ id: f.id,
3183
+ severity: f.severity === "hard" ? "block" : "risk",
3184
+ title: f.title,
3185
+ detail: f.detail,
3186
+ suggestion: f.suggestion ?? null
3187
+ }))
3188
+ ];
3039
3189
  const sortedDims = [...dimensions].sort((a, b2) => a.score - b2.score);
3040
3190
  const worst = sortedDims[0];
3041
3191
  const best = sortedDims[sortedDims.length - 1];
@@ -3048,6 +3198,7 @@ function scoreResume(resume) {
3048
3198
  summary: `Strongest: ${best.label} (${best.score}). Focus area: ${worst.label} (${worst.score}).`,
3049
3199
  priorities,
3050
3200
  hardFails: dimensions.flatMap((d) => d.checks.filter((c) => c.severity === "hard" && c.status === "fail").map((c) => ({ ...c, dim: d.dimension }))),
3201
+ flags,
3051
3202
  stats: {
3052
3203
  totalBullets: b.bullets.length,
3053
3204
  experienceBullets: b.expBullets.length,
@@ -3069,6 +3220,10 @@ var CLOUD = CLOUD_TOKEN.length > 0;
3069
3220
  async function ensure(dir) {
3070
3221
  await fs.mkdir(dir, { recursive: true });
3071
3222
  }
3223
+ function assertSafeId(id) {
3224
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(id)) throw new Error(`JuicedResume: invalid resume id "${id}".`);
3225
+ return id;
3226
+ }
3072
3227
  async function cloudReq(method, p, body) {
3073
3228
  let res;
3074
3229
  try {
@@ -3109,6 +3264,7 @@ async function listResumes() {
3109
3264
  return out.sort((a, b) => a.updatedAt < b.updatedAt ? 1 : -1);
3110
3265
  }
3111
3266
  async function getResume(id) {
3267
+ assertSafeId(id);
3112
3268
  if (CLOUD) {
3113
3269
  const data = await cloudReq("GET", `/resumes/${id}`);
3114
3270
  return data ? Resume.parse(data) : null;
@@ -3121,6 +3277,7 @@ async function getResume(id) {
3121
3277
  }
3122
3278
  }
3123
3279
  async function saveResume(r) {
3280
+ assertSafeId(r.id);
3124
3281
  const next = { ...r, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
3125
3282
  if (CLOUD) {
3126
3283
  await cloudReq("PUT", `/resumes/${r.id}`, next);
@@ -3150,6 +3307,7 @@ async function createBlankResume(name = "Untitled") {
3150
3307
  return await saveResume(blank);
3151
3308
  }
3152
3309
  async function deleteResume(id) {
3310
+ assertSafeId(id);
3153
3311
  if (CLOUD) {
3154
3312
  await cloudReq("DELETE", `/resumes/${id}`);
3155
3313
  return;
@@ -3187,7 +3345,7 @@ function resumeToLatex(r) {
3187
3345
  {\\LARGE \\textbf{${tex(p.fullName || "Your Name")}}}\\\\[2pt]
3188
3346
  ${p.title ? `{\\itshape ${tex(p.title)}}\\\\[2pt]` : ""}
3189
3347
  ${[
3190
- p.email && `\\href{mailto:${p.email}}{${tex(p.email)}}`,
3348
+ p.email && `\\href{mailto:${texUrl(p.email)}}{${tex(p.email)}}`,
3191
3349
  p.phone && tex(p.phone),
3192
3350
  p.location && tex(p.location),
3193
3351
  p.linkedin && hrefHandle(p.linkedin),
@@ -3232,7 +3390,7 @@ ${i.description ? htmlToTex(i.description) : ""}
3232
3390
  return `${header}
3233
3391
  ${items.map((i) => `
3234
3392
  \\noindent\\textbf{${tex(i.name)}}${i.role ? ` --- ${tex(i.role)}` : ""}\\hfill ${tex(dateRange(i.startDate, i.endDate, false, r))}\\\\
3235
- ${i.link ? `\\href{${ensureProto(i.link)}}{${tex(i.link)}}\\\\` : ""}
3393
+ ${i.link ? `\\href{${texUrl(ensureProto(i.link))}}{${tex(i.link)}}\\\\` : ""}
3236
3394
  ${htmlToTex(i.description)}
3237
3395
  `).join("\n")}
3238
3396
  `;
@@ -3252,7 +3410,7 @@ ${items.map((i) => `
3252
3410
  return `${header}
3253
3411
  \\begin{itemize}
3254
3412
  ${items.map(
3255
- (i) => ` \\item \\textbf{${tex(i.name)}}${i.issuer ? ` --- ${tex(i.issuer)}` : ""}${i.date ? ` (${tex(i.date)})` : ""}${i.link ? ` \\href{${ensureProto(i.link)}}{link}` : ""}`
3413
+ (i) => ` \\item \\textbf{${tex(i.name)}}${i.issuer ? ` --- ${tex(i.issuer)}` : ""}${i.date ? ` (${tex(i.date)})` : ""}${i.link ? ` \\href{${texUrl(ensureProto(i.link))}}{link}` : ""}`
3256
3414
  ).join("\n")}
3257
3415
  \\end{itemize}
3258
3416
  `;
@@ -3286,7 +3444,7 @@ ${i.description ? htmlToTex(i.description) : ""}
3286
3444
  return `${header}
3287
3445
  ${items.map((i) => `
3288
3446
  \\noindent\\textbf{${tex(i.title)}}${i.publisher ? ` --- ${tex(i.publisher)}` : ""}${i.date ? `\\hfill ${tex(i.date)}` : ""}\\\\
3289
- ${i.link ? `\\href{${ensureProto(i.link)}}{${tex(i.link)}}\\\\` : ""}
3447
+ ${i.link ? `\\href{${texUrl(ensureProto(i.link))}}{${tex(i.link)}}\\\\` : ""}
3290
3448
  ${i.description ? htmlToTex(i.description) : ""}
3291
3449
  `).join("\n")}
3292
3450
  `;
@@ -3357,7 +3515,7 @@ function inlineHtmlToTex(html) {
3357
3515
  const href = m[1];
3358
3516
  const end = findCloseTag(src, close + 1, "a");
3359
3517
  const inner = src.slice(close + 1, end.start);
3360
- out += `\\href{${href}}{${inlineHtmlToTex(inner)}}`;
3518
+ out += `\\href{${texUrl(decodeEntities(href))}}{${inlineHtmlToTex(inner)}}`;
3361
3519
  i = end.after;
3362
3520
  continue;
3363
3521
  }
@@ -3393,10 +3551,13 @@ ${after}`;
3393
3551
  return html.split(/<\/?p[^>]*>/gi).map((c) => inlineHtmlToTex(c).trim()).filter(Boolean).join("\\\\\n");
3394
3552
  }
3395
3553
  function sanitizeColor(hex) {
3396
- return hex.replace(/^#/, "").slice(0, 6).padEnd(6, "0");
3554
+ return hex.replace(/[^0-9a-fA-F]/g, "").slice(0, 6).padEnd(6, "0");
3555
+ }
3556
+ function texUrl(s) {
3557
+ return s.replace(/[{}\\\s"]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0"));
3397
3558
  }
3398
3559
  function hrefHandle(s) {
3399
- return `\\href{${ensureProto(s)}}{${tex(s)}}`;
3560
+ return `\\href{${texUrl(ensureProto(s))}}{${tex(s)}}`;
3400
3561
  }
3401
3562
  function ensureProto(s) {
3402
3563
  if (/^https?:\/\//i.test(s)) return s;
@@ -3497,8 +3658,12 @@ var ok = (data) => ({ content: [{ type: "text", text: typeof data === "string" ?
3497
3658
  async function mutate(id, fn) {
3498
3659
  const r = await getResume(id);
3499
3660
  if (!r) throw new Error(`Resume not found: ${id}`);
3500
- const next = fn(r);
3501
- return await saveResume(next);
3661
+ const parsed = Resume.safeParse(fn(r));
3662
+ if (!parsed.success) {
3663
+ const first = parsed.error.issues[0];
3664
+ throw new Error(`Invalid patch \u2014 resume would no longer validate (${first?.path.join(".")}: ${first?.message}).`);
3665
+ }
3666
+ return await saveResume(parsed.data);
3502
3667
  }
3503
3668
  var TOOLS = [
3504
3669
  // discovery
@@ -3577,7 +3742,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
3577
3742
  case "add_section":
3578
3743
  return ok(await mutate(args.id, (r) => {
3579
3744
  const type = args.type;
3580
- if (!DEFAULT_SECTION_TITLES[type]) throw new Error(`unknown section type: ${type}`);
3745
+ if (!Object.hasOwn(DEFAULT_SECTION_TITLES, type)) throw new Error(`unknown section type: ${type}`);
3581
3746
  const section = { id: newId(), type, title: args.title || DEFAULT_SECTION_TITLES[type], visible: true, columns: 1, items: [] };
3582
3747
  return { ...r, sections: [...r.sections, section] };
3583
3748
  }));
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@juicedresume/mcp",
3
- "version": "0.3.2",
3
+ "version": "0.4.1",
4
+ "mcpName": "com.juicedresume/mcp",
4
5
  "description": "Model Context Protocol server for JuicedResume — score, tailor, and edit your resume from any MCP client (Claude Code, Claude Desktop, Cursor).",
5
6
  "keywords": [
6
7
  "mcp",