@juicedresume/mcp 0.4.0 → 0.4.2

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 +57 -11
  2. package/package.json +4 -4
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"),
@@ -3188,6 +3220,10 @@ var CLOUD = CLOUD_TOKEN.length > 0;
3188
3220
  async function ensure(dir) {
3189
3221
  await fs.mkdir(dir, { recursive: true });
3190
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
+ }
3191
3227
  async function cloudReq(method, p, body) {
3192
3228
  let res;
3193
3229
  try {
@@ -3228,6 +3264,7 @@ async function listResumes() {
3228
3264
  return out.sort((a, b) => a.updatedAt < b.updatedAt ? 1 : -1);
3229
3265
  }
3230
3266
  async function getResume(id) {
3267
+ assertSafeId(id);
3231
3268
  if (CLOUD) {
3232
3269
  const data = await cloudReq("GET", `/resumes/${id}`);
3233
3270
  return data ? Resume.parse(data) : null;
@@ -3240,6 +3277,7 @@ async function getResume(id) {
3240
3277
  }
3241
3278
  }
3242
3279
  async function saveResume(r) {
3280
+ assertSafeId(r.id);
3243
3281
  const next = { ...r, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
3244
3282
  if (CLOUD) {
3245
3283
  await cloudReq("PUT", `/resumes/${r.id}`, next);
@@ -3269,6 +3307,7 @@ async function createBlankResume(name = "Untitled") {
3269
3307
  return await saveResume(blank);
3270
3308
  }
3271
3309
  async function deleteResume(id) {
3310
+ assertSafeId(id);
3272
3311
  if (CLOUD) {
3273
3312
  await cloudReq("DELETE", `/resumes/${id}`);
3274
3313
  return;
@@ -3306,7 +3345,7 @@ function resumeToLatex(r) {
3306
3345
  {\\LARGE \\textbf{${tex(p.fullName || "Your Name")}}}\\\\[2pt]
3307
3346
  ${p.title ? `{\\itshape ${tex(p.title)}}\\\\[2pt]` : ""}
3308
3347
  ${[
3309
- p.email && `\\href{mailto:${p.email}}{${tex(p.email)}}`,
3348
+ p.email && `\\href{mailto:${texUrl(p.email)}}{${tex(p.email)}}`,
3310
3349
  p.phone && tex(p.phone),
3311
3350
  p.location && tex(p.location),
3312
3351
  p.linkedin && hrefHandle(p.linkedin),
@@ -3351,7 +3390,7 @@ ${i.description ? htmlToTex(i.description) : ""}
3351
3390
  return `${header}
3352
3391
  ${items.map((i) => `
3353
3392
  \\noindent\\textbf{${tex(i.name)}}${i.role ? ` --- ${tex(i.role)}` : ""}\\hfill ${tex(dateRange(i.startDate, i.endDate, false, r))}\\\\
3354
- ${i.link ? `\\href{${ensureProto(i.link)}}{${tex(i.link)}}\\\\` : ""}
3393
+ ${i.link ? `\\href{${texUrl(ensureProto(i.link))}}{${tex(i.link)}}\\\\` : ""}
3355
3394
  ${htmlToTex(i.description)}
3356
3395
  `).join("\n")}
3357
3396
  `;
@@ -3371,7 +3410,7 @@ ${items.map((i) => `
3371
3410
  return `${header}
3372
3411
  \\begin{itemize}
3373
3412
  ${items.map(
3374
- (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}` : ""}`
3375
3414
  ).join("\n")}
3376
3415
  \\end{itemize}
3377
3416
  `;
@@ -3405,7 +3444,7 @@ ${i.description ? htmlToTex(i.description) : ""}
3405
3444
  return `${header}
3406
3445
  ${items.map((i) => `
3407
3446
  \\noindent\\textbf{${tex(i.title)}}${i.publisher ? ` --- ${tex(i.publisher)}` : ""}${i.date ? `\\hfill ${tex(i.date)}` : ""}\\\\
3408
- ${i.link ? `\\href{${ensureProto(i.link)}}{${tex(i.link)}}\\\\` : ""}
3447
+ ${i.link ? `\\href{${texUrl(ensureProto(i.link))}}{${tex(i.link)}}\\\\` : ""}
3409
3448
  ${i.description ? htmlToTex(i.description) : ""}
3410
3449
  `).join("\n")}
3411
3450
  `;
@@ -3476,7 +3515,7 @@ function inlineHtmlToTex(html) {
3476
3515
  const href = m[1];
3477
3516
  const end = findCloseTag(src, close + 1, "a");
3478
3517
  const inner = src.slice(close + 1, end.start);
3479
- out += `\\href{${href}}{${inlineHtmlToTex(inner)}}`;
3518
+ out += `\\href{${texUrl(decodeEntities(href))}}{${inlineHtmlToTex(inner)}}`;
3480
3519
  i = end.after;
3481
3520
  continue;
3482
3521
  }
@@ -3512,10 +3551,13 @@ ${after}`;
3512
3551
  return html.split(/<\/?p[^>]*>/gi).map((c) => inlineHtmlToTex(c).trim()).filter(Boolean).join("\\\\\n");
3513
3552
  }
3514
3553
  function sanitizeColor(hex) {
3515
- 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"));
3516
3558
  }
3517
3559
  function hrefHandle(s) {
3518
- return `\\href{${ensureProto(s)}}{${tex(s)}}`;
3560
+ return `\\href{${texUrl(ensureProto(s))}}{${tex(s)}}`;
3519
3561
  }
3520
3562
  function ensureProto(s) {
3521
3563
  if (/^https?:\/\//i.test(s)) return s;
@@ -3616,8 +3658,12 @@ var ok = (data) => ({ content: [{ type: "text", text: typeof data === "string" ?
3616
3658
  async function mutate(id, fn) {
3617
3659
  const r = await getResume(id);
3618
3660
  if (!r) throw new Error(`Resume not found: ${id}`);
3619
- const next = fn(r);
3620
- 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);
3621
3667
  }
3622
3668
  var TOOLS = [
3623
3669
  // discovery
@@ -3696,7 +3742,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
3696
3742
  case "add_section":
3697
3743
  return ok(await mutate(args.id, (r) => {
3698
3744
  const type = args.type;
3699
- 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}`);
3700
3746
  const section = { id: newId(), type, title: args.title || DEFAULT_SECTION_TITLES[type], visible: true, columns: 1, items: [] };
3701
3747
  return { ...r, sections: [...r.sections, section] };
3702
3748
  }));
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@juicedresume/mcp",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
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",
@@ -14,8 +15,7 @@
14
15
  "homepage": "https://juicedresume.com",
15
16
  "repository": {
16
17
  "type": "git",
17
- "url": "git+https://github.com/HarshitSingh-PM/juicedresume.git",
18
- "directory": "apps/mcp"
18
+ "url": "git+https://github.com/HarshitSingh-PM/juicedresume-mcp.git"
19
19
  },
20
20
  "license": "MIT",
21
21
  "author": "Harshit Singh <harshit@halacarly.com>",
@@ -55,4 +55,4 @@
55
55
  "publishConfig": {
56
56
  "access": "public"
57
57
  }
58
- }
58
+ }