@sindhujaks/career-mcp 1.0.0 → 1.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/.env.example CHANGED
@@ -9,6 +9,13 @@
9
9
  # Defaults to ~/.career-mcp
10
10
  CAREER_MCP_DATA_DIR=
11
11
 
12
+ # ─── Optional — PDF export (both the MCP server and the CLI) ──────────────────
13
+ # Default theme and page size; each export can still override them.
14
+ # CAREER_MCP_PDF_THEME=navy # navy | minimal | classic
15
+ # CAREER_MCP_PAGE_SIZE=letter # letter | a4
16
+ # Only needed if your Chrome/Edge/Chromium/Brave isn't in a standard location.
17
+ # CAREER_MCP_CHROME_PATH=
18
+
12
19
  # ─── Required for the standalone CLI only ─────────────────────────────────────
13
20
  # Bring your own key — career-mcp never calls a shared/hosted key, so there's
14
21
  # no cost or liability on anyone but you.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # career-mcp
2
2
 
3
- An MCP server for the job-search loop: store a resume, drop in a job description, get a real match analysis, a tailored resume, a cover letter, a LinkedIn About section — plus a structured interview that captures your career stories so they're ready to reuse.
3
+ An MCP server for the job-search loop: store a resume, drop in a job description, get a real match analysis, a tailored resume and cover letter (exported to PDF), a LinkedIn About section — plus a structured interview that captures your career stories so they're ready to reuse.
4
4
 
5
5
  Local-first. Your resume and stories live in a folder on your machine (`~/.career-mcp` by default) — nothing is uploaded anywhere.
6
6
 
@@ -42,9 +42,38 @@ Then, in conversation: save your resume, save a JD, and ask your client to run a
42
42
  | `get_active_application` | Bundles resume + JD + voice profile + relevant stories as context |
43
43
  | `list_applications` | List all stored applications |
44
44
  | `save_output` | Persist a generated match analysis / resume / cover letter / About section |
45
+ | `export_pdf` | Render a tailored resume, cover letter, or base resume to PDF (themes: `navy`, `minimal`, `classic`; page size: `letter` or `a4`) |
45
46
  | `save_story` / `query_stories` | Save/retrieve career stories, filterable by tag |
46
47
  | `start_story_interview` / `answer_story_question` | Guided, protocol-driven interview to capture one new story |
47
48
 
49
+ ## PDF export
50
+
51
+ `export_pdf` turns a saved markdown resume or cover letter into a PDF that ATS systems can read (real, selectable text — not an image).
52
+
53
+ - **Uses the browser you already have.** It drives your installed Chrome, Edge, Chromium, or Brave in the background — nothing extra is downloaded. If yours lives somewhere unusual, set `CAREER_MCP_CHROME_PATH`. If no browser is found, you get an `.html` file instead: open it and use Print → Save as PDF.
54
+ - **Works offline.** Fonts are bundled with the package.
55
+ - **Three themes:** `navy` (default — modern sans-serif, navy accents), `minimal` (monochrome, compact), `classic` (serif, centered header).
56
+ - **Letter or A4.** Defaults to Letter; set `CAREER_MCP_PAGE_SIZE=a4` to change the default.
57
+ - **Cover letters only on request** — the default document is the tailored resume.
58
+
59
+ The PDF is written next to the markdown (e.g. `outputs/tailored_resume.pdf`). The markdown stays the source of truth: edit it, then export again.
60
+
61
+ Resumes render best in this shape (the MCP client is told this automatically):
62
+
63
+ ```markdown
64
+ # Full Name
65
+ Target title · positioning
66
+ City · email · phone · linkedin.com/in/you
67
+
68
+ ## Summary
69
+ ...
70
+
71
+ ## Experience
72
+ ### Job Title — Company
73
+ *2021 – Present | City*
74
+ - Achievement bullet
75
+ ```
76
+
48
77
  ## Use standalone (no MCP client)
49
78
 
50
79
  ```bash
@@ -53,8 +82,9 @@ cp .env.example .env # fill in CAREER_MCP_API_KEY (free Groq or Gemini key)
53
82
  career-mcp-cli set-resume --file resume.txt
54
83
  career-mcp-cli set-jd --company Acme --role "Senior Engineer" --file jd.txt
55
84
  career-mcp-cli analyze
56
- career-mcp-cli resume
57
- career-mcp-cli cover-letter
85
+ career-mcp-cli resume # also writes the PDF
86
+ career-mcp-cli cover-letter --pdf # --pdf is optional
87
+ career-mcp-cli pdf --theme classic --page-size a4
58
88
  career-mcp-cli about
59
89
  career-mcp-cli interview # guided story capture
60
90
  ```
@@ -69,6 +99,7 @@ career-mcp-cli interview # guided story capture
69
99
  └── applications/<company-role>/
70
100
  ├── jd.json
71
101
  └── outputs/{match_analysis,tailored_resume,cover_letter,linkedin_about}.md
102
+ (+ tailored_resume.pdf / cover_letter.pdf once exported)
72
103
  ```
73
104
 
74
105
  Override the location with `CAREER_MCP_DATA_DIR`.
package/dist/cli.js CHANGED
@@ -5,6 +5,8 @@ import "dotenv/config";
5
5
  import * as storage from "./storage.js";
6
6
  import { startStoryInterview, answerStoryQuestion } from "./interview.js";
7
7
  import { callLLM } from "./llm-fallback.js";
8
+ import { exportPdf, describeResult } from "./export.js";
9
+ import { RESUME_MARKDOWN_GUIDE } from "./render/markdown.js";
8
10
  const SYSTEM_PROMPT = "You are a career writing assistant. Write in a clear, direct, honest voice — " +
9
11
  "no buzzwords, no AI-sounding phrases ('in today's fast-paced world', 'as we navigate'), " +
10
12
  "no fake inspiration. Prefer specific, grounded claims over impressive-sounding generalities. " +
@@ -72,6 +74,15 @@ async function cmdGenerate(outputType, instruction) {
72
74
  await storage.saveOutput(ctx.application.slug, outputType, result);
73
75
  console.log(`\n(saved to ~/.career-mcp/applications/${ctx.application.slug}/outputs/${outputType}.md)`);
74
76
  }
77
+ async function cmdPdf(flags, document) {
78
+ const result = await exportPdf({
79
+ document: document ?? flags.document,
80
+ applicationSlug: flags.application,
81
+ theme: flags.theme,
82
+ pageSize: flags["page-size"],
83
+ });
84
+ console.log(describeResult(result));
85
+ }
75
86
  async function cmdInterview() {
76
87
  const rl = readline.createInterface({ input: stdin, output: stdout });
77
88
  let step = startStoryInterview();
@@ -97,9 +108,15 @@ async function main() {
97
108
  case "analyze":
98
109
  return cmdGenerate("match_analysis", "Analyze how well this resume matches this job description. Call out genuine overlaps and honest gaps — no keyword stuffing.");
99
110
  case "resume":
100
- return cmdGenerate("tailored_resume", "Produce a tailored version of this resume for this job description, reordering and reframing existing content for relevance. Do not invent experience.");
111
+ await cmdGenerate("tailored_resume", `Produce a tailored version of this resume for this job description, reordering and reframing existing content for relevance. Do not invent experience.\n\n${RESUME_MARKDOWN_GUIDE}`);
112
+ return cmdPdf(flags, "tailored_resume");
101
113
  case "cover-letter":
102
- return cmdGenerate("cover_letter", "Write a cover letter for this job description, in the voice profile's tone. A real letter: date, Dear Hiring Team, body, Sincerely + name.");
114
+ await cmdGenerate("cover_letter", "Write a cover letter for this job description, in the voice profile's tone. A real letter: date, Dear Hiring Team, body, Sincerely + name. Output only the letter, as markdown.");
115
+ if (flags.pdf)
116
+ return cmdPdf(flags, "cover_letter");
117
+ return;
118
+ case "pdf":
119
+ return cmdPdf(flags);
103
120
  case "about":
104
121
  return cmdGenerate("linkedin_about", "Write a LinkedIn About section based on this resume and these stories, in the voice profile's tone.");
105
122
  default:
@@ -112,9 +129,13 @@ async function main() {
112
129
  " set-jd --company <name> --role <title> --file <path>",
113
130
  " interview",
114
131
  " analyze",
115
- " resume",
116
- " cover-letter",
132
+ " resume (also writes a PDF)",
133
+ " cover-letter [--pdf]",
117
134
  " about",
135
+ " pdf [--document tailored_resume|cover_letter|base_resume] [--theme navy|minimal|classic]",
136
+ " [--page-size letter|a4] [--application <slug>]",
137
+ "",
138
+ " resume and cover-letter --pdf also accept --theme and --page-size.",
118
139
  ].join("\n"));
119
140
  }
120
141
  }
package/dist/export.js ADDED
@@ -0,0 +1,55 @@
1
+ import * as storage from "./storage.js";
2
+ import { renderMarkdownToPdf } from "./render/pdf.js";
3
+ import { PAGE_SIZES } from "./render/markdown.js";
4
+ import { THEME_NAMES } from "./render/themes.js";
5
+ export const EXPORT_DOCUMENTS = ["tailored_resume", "cover_letter", "base_resume"];
6
+ function resolveTheme(theme) {
7
+ const value = (theme || process.env.CAREER_MCP_PDF_THEME || "navy").toLowerCase();
8
+ if (!THEME_NAMES.includes(value)) {
9
+ throw new Error(`Unknown theme "${value}". Choose one of: ${THEME_NAMES.join(", ")}.`);
10
+ }
11
+ return value;
12
+ }
13
+ function resolvePageSize(pageSize) {
14
+ const value = (pageSize || process.env.CAREER_MCP_PAGE_SIZE || "letter").toLowerCase();
15
+ if (!PAGE_SIZES.includes(value)) {
16
+ throw new Error(`Unknown page size "${value}". Choose one of: ${PAGE_SIZES.join(", ")}.`);
17
+ }
18
+ return value;
19
+ }
20
+ export async function exportPdf(options = {}) {
21
+ const document = options.document ?? "tailored_resume";
22
+ if (!EXPORT_DOCUMENTS.includes(document)) {
23
+ throw new Error(`Unknown document "${document}". Choose one of: ${EXPORT_DOCUMENTS.join(", ")}.`);
24
+ }
25
+ const renderOptions = { theme: resolveTheme(options.theme), pageSize: resolvePageSize(options.pageSize) };
26
+ if (document === "base_resume") {
27
+ const resume = await storage.getResume();
28
+ if (!resume?.rawText)
29
+ throw new Error("No resume stored yet. Use save_resume first.");
30
+ return renderMarkdownToPdf(resume.rawText, storage.baseResumeOutputBasePath(), { ...renderOptions, title: "Resume", kind: "resume" });
31
+ }
32
+ let slug = options.applicationSlug;
33
+ if (!slug) {
34
+ const active = await storage.getActiveApplication();
35
+ if (!active)
36
+ throw new Error("No active application and no application_slug provided.");
37
+ slug = active.slug;
38
+ }
39
+ const application = await storage.getApplication(slug);
40
+ if (!application)
41
+ throw new Error(`No application found with slug "${slug}".`);
42
+ const markdown = await storage.getOutput(slug, document);
43
+ if (!markdown) {
44
+ throw new Error(`No ${document} saved for ${application.company} — ${application.role} yet. Generate it and save it with save_output first.`);
45
+ }
46
+ const isLetter = document === "cover_letter";
47
+ return renderMarkdownToPdf(markdown, storage.outputBasePath(slug, document), {
48
+ ...renderOptions,
49
+ title: `${isLetter ? "Cover Letter" : "Resume"} — ${application.company}`,
50
+ kind: isLetter ? "letter" : "resume",
51
+ });
52
+ }
53
+ export function describeResult(result) {
54
+ return result.ok ? `PDF saved to ${result.pdfPath}` : `Could not create the PDF. ${result.reason}`;
55
+ }
package/dist/index.js CHANGED
@@ -4,6 +4,9 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
5
  import * as storage from "./storage.js";
6
6
  import { startStoryInterview, answerStoryQuestion } from "./interview.js";
7
+ import { exportPdf, describeResult, EXPORT_DOCUMENTS } from "./export.js";
8
+ import { RESUME_MARKDOWN_GUIDE, PAGE_SIZES } from "./render/markdown.js";
9
+ import { THEME_NAMES } from "./render/themes.js";
7
10
  const server = new Server({ name: "career-mcp", version: "1.0.0" }, { capabilities: { tools: {} } });
8
11
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
9
12
  tools: [
@@ -55,7 +58,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
55
58
  },
56
59
  {
57
60
  name: "get_active_application",
58
- description: "Returns the stored resume, voice profile, and the active job description together, as context for match analysis or content generation. Use this to: (1) run match analysis — identify genuine overlaps and honest gaps between the resume and JD, not keyword stuffing; (2) draft a tailored resume by reordering/reframing existing resume content for relevance, never fabricating experience; (3) draft a cover letter; (4) draft a LinkedIn About section. Match the voice profile's tone if one is set. Save any generated output with save_output.",
61
+ description: `Returns the stored resume, voice profile, and the active job description together, as context for match analysis or content generation. Use this to: (1) run match analysis — identify genuine overlaps and honest gaps between the resume and JD, not keyword stuffing; (2) draft a tailored resume by reordering/reframing existing resume content for relevance, never fabricating experience; (3) draft a cover letter; (4) draft a LinkedIn About section. Match the voice profile's tone if one is set. Save any generated output with save_output.\n\n${RESUME_MARKDOWN_GUIDE}`,
59
62
  inputSchema: { type: "object", properties: {} },
60
63
  },
61
64
  {
@@ -65,7 +68,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
65
68
  },
66
69
  {
67
70
  name: "save_output",
68
- description: "Persist generated content (match analysis, tailored resume, cover letter, or LinkedIn About draft) for an application. Defaults to the active application if application_slug is omitted.",
71
+ description: "Persist generated content (match analysis, tailored resume, cover letter, or LinkedIn About draft) for an application. Defaults to the active application if application_slug is omitted. After saving a tailored resume, offer to export it with export_pdf.",
69
72
  inputSchema: {
70
73
  type: "object",
71
74
  properties: {
@@ -79,6 +82,23 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
79
82
  required: ["output_type", "content"],
80
83
  },
81
84
  },
85
+ {
86
+ name: "export_pdf",
87
+ description: "Render a saved markdown document to a PDF that ATS systems can read, using the user's installed Chrome/Edge/Chromium/Brave. Defaults to the active application's tailored resume. Only export a cover letter when the user asks for one. If no browser is found, an HTML file is written instead and the reason is returned — relay it to the user.",
88
+ inputSchema: {
89
+ type: "object",
90
+ properties: {
91
+ document: {
92
+ type: "string",
93
+ enum: EXPORT_DOCUMENTS,
94
+ description: "tailored_resume (default), cover_letter, or base_resume (the stored base resume)",
95
+ },
96
+ application_slug: { type: "string", description: "Optional — defaults to the active application. Ignored for base_resume." },
97
+ theme: { type: "string", enum: THEME_NAMES, description: "navy (default: modern sans, navy accents), minimal (monochrome, compact), classic (serif, centered header)" },
98
+ page_size: { type: "string", enum: PAGE_SIZES, description: "letter (default) or a4" },
99
+ },
100
+ },
101
+ },
82
102
  {
83
103
  name: "save_story",
84
104
  description: "Save a career story directly (title, tags, content) without running the interview flow. Prefer start_story_interview for a guided capture.",
@@ -199,6 +219,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
199
219
  const filePath = await storage.saveOutput(slug, output_type, content);
200
220
  return textResult(`Saved ${output_type} to ${filePath}`);
201
221
  }
222
+ case "export_pdf": {
223
+ const { document, application_slug, theme, page_size } = args;
224
+ const result = await exportPdf({ document, applicationSlug: application_slug, theme, pageSize: page_size });
225
+ return result.ok ? textResult(describeResult(result)) : { ...textResult(describeResult(result)), isError: true };
226
+ }
202
227
  case "save_story": {
203
228
  const { title, tags, content } = args;
204
229
  const story = await storage.saveStory(title, tags, content);
@@ -0,0 +1,24 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { createRequire } from "node:module";
3
+ import path from "node:path";
4
+ // Fonts ship as npm dependencies (@fontsource/*, all SIL OFL) and are inlined
5
+ // into the HTML as data URIs, so rendering never needs a network connection.
6
+ const require = createRequire(import.meta.url);
7
+ async function faceCss(face, weight, style) {
8
+ const pkgDir = path.dirname(require.resolve(`${face.pkg}/package.json`));
9
+ const file = path.join(pkgDir, "files", `${face.filePrefix}-latin-${weight}-${style}.woff2`);
10
+ const data = (await readFile(file)).toString("base64");
11
+ return `@font-face {
12
+ font-family: '${face.family}';
13
+ font-style: ${style};
14
+ font-weight: ${weight};
15
+ src: url(data:font/woff2;base64,${data}) format('woff2');
16
+ }`;
17
+ }
18
+ export async function fontFaceCss(face) {
19
+ const rules = await Promise.all([
20
+ ...face.weights.map((w) => faceCss(face, w, "normal")),
21
+ ...face.italicWeights.map((w) => faceCss(face, w, "italic")),
22
+ ]);
23
+ return rules.join("\n");
24
+ }
@@ -0,0 +1,87 @@
1
+ import { marked } from "marked";
2
+ import { THEMES, SHARED_CSS } from "./themes.js";
3
+ import { fontFaceCss } from "./fonts.js";
4
+ export const PAGE_SIZES = ["letter", "a4"];
5
+ // Handed to the MCP client (and the CLI's LLM) so generated resumes match what the themes style.
6
+ export const RESUME_MARKDOWN_GUIDE = [
7
+ "Write the tailored resume as markdown in this exact shape so it renders cleanly to PDF:",
8
+ " # Full Name",
9
+ " One-line tagline (target title / positioning)",
10
+ " Contact line: City · email · phone · linkedin.com/in/... (one paragraph)",
11
+ " ## Summary (then a short paragraph)",
12
+ " ## Experience",
13
+ " ### Job Title — Company",
14
+ " *Start – End | Location*",
15
+ " - achievement bullets",
16
+ " ## Skills (bullets, or a two-column table: | Area | Skills |)",
17
+ " ## Education",
18
+ "Output only the resume itself — no commentary before or after it.",
19
+ ].join("\n");
20
+ // LLM output often arrives fenced (```markdown ... ```) or with a chatty preamble,
21
+ // and resume files may carry private notes. Strip all of that before rendering.
22
+ export function cleanMarkdown(markdown) {
23
+ let text = markdown.trim();
24
+ const fenced = text.match(/^```(?:markdown|md)?\s*\n([\s\S]*?)\n```$/i);
25
+ if (fenced)
26
+ text = fenced[1].trim();
27
+ const lines = text.split("\n");
28
+ const firstH1 = lines.findIndex((line) => /^# /.test(line));
29
+ const start = firstH1 === -1 ? 0 : firstH1;
30
+ const notes = lines.findIndex((line, i) => i > start && /^## Notes for Customization/i.test(line));
31
+ const end = notes === -1 ? lines.length : notes;
32
+ return separateStructuralLines(lines.slice(start, end)).join("\n").trim();
33
+ }
34
+ // The themes style lines by position (h1 + p + p is the contact line, h3 + p the dates),
35
+ // but markdown folds consecutive lines into one paragraph. Give each plain line its own
36
+ // paragraph in the header block (between # Name and the first ##) and right after a ###/####.
37
+ function separateStructuralLines(lines) {
38
+ const isPlain = (line) => line.trim() !== "" && !/^\s*([-+]\s|\*\s|\d+\.\s|[|>#]|```)/.test(line);
39
+ const out = [];
40
+ let inHeaderBlock = false;
41
+ let afterSubheading = false;
42
+ for (const line of lines) {
43
+ if (/^# /.test(line))
44
+ inHeaderBlock = true;
45
+ else if (/^## /.test(line))
46
+ inHeaderBlock = false;
47
+ // A ###/#### opens a run of plain lines (dates, role description); bullets, tables, etc. end it.
48
+ if (/^#{3,4} /.test(line))
49
+ afterSubheading = true;
50
+ else if (line.trim() !== "" && !isPlain(line))
51
+ afterSubheading = false;
52
+ out.push(line);
53
+ if ((inHeaderBlock || afterSubheading) && isPlain(line))
54
+ out.push("");
55
+ }
56
+ return out;
57
+ }
58
+ export async function markdownToHtml(markdown, options) {
59
+ const theme = THEMES[options.theme];
60
+ // Letters keep single line breaks ("Sincerely,\nName"); resumes use standard markdown paragraphs.
61
+ const body = await marked.parse(cleanMarkdown(markdown), { breaks: options.kind === "letter" });
62
+ const fonts = await fontFaceCss(theme.font);
63
+ const pageSize = options.pageSize === "a4" ? "A4" : "letter";
64
+ return `<!DOCTYPE html>
65
+ <html lang="en">
66
+ <head>
67
+ <meta charset="UTF-8">
68
+ <title>${escapeHtml(options.title)}</title>
69
+ <style>
70
+ ${fonts}
71
+ @page {
72
+ size: ${pageSize};
73
+ margin: 0.6in 0.75in;
74
+ }
75
+ ${SHARED_CSS}
76
+ ${theme.css}
77
+ </style>
78
+ </head>
79
+ <body class="${options.kind}">
80
+ ${body}
81
+ </body>
82
+ </html>
83
+ `;
84
+ }
85
+ function escapeHtml(text) {
86
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
87
+ }
@@ -0,0 +1,93 @@
1
+ import { access, writeFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import puppeteer from "puppeteer-core";
5
+ import { markdownToHtml } from "./markdown.js";
6
+ // puppeteer-core drives a browser the user already has installed — it never
7
+ // downloads one. Set CAREER_MCP_CHROME_PATH to point at a browser we don't find.
8
+ function candidateBrowserPaths() {
9
+ const home = os.homedir();
10
+ switch (process.platform) {
11
+ case "darwin":
12
+ return [
13
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
14
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
15
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
16
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
17
+ path.join(home, "Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
18
+ ];
19
+ case "win32": {
20
+ const roots = [process.env["PROGRAMFILES"], process.env["PROGRAMFILES(X86)"], process.env["LOCALAPPDATA"]].filter((root) => Boolean(root));
21
+ return roots.flatMap((root) => [
22
+ path.join(root, "Google", "Chrome", "Application", "chrome.exe"),
23
+ path.join(root, "Microsoft", "Edge", "Application", "msedge.exe"),
24
+ path.join(root, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
25
+ ]);
26
+ }
27
+ default:
28
+ return [
29
+ "/usr/bin/google-chrome",
30
+ "/usr/bin/google-chrome-stable",
31
+ "/usr/bin/chromium",
32
+ "/usr/bin/chromium-browser",
33
+ "/usr/bin/microsoft-edge",
34
+ "/usr/bin/brave-browser",
35
+ "/snap/bin/chromium",
36
+ ];
37
+ }
38
+ }
39
+ async function exists(p) {
40
+ try {
41
+ await access(p);
42
+ return true;
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ }
48
+ export async function findBrowser() {
49
+ const override = process.env.CAREER_MCP_CHROME_PATH;
50
+ if (override) {
51
+ if (await exists(override))
52
+ return override;
53
+ throw new Error(`CAREER_MCP_CHROME_PATH is set to ${override}, but no file exists there.`);
54
+ }
55
+ for (const candidate of candidateBrowserPaths()) {
56
+ if (await exists(candidate))
57
+ return candidate;
58
+ }
59
+ return null;
60
+ }
61
+ // Writes <outBase>.pdf. If no browser is available, writes <outBase>.html instead
62
+ // so the user can open it and Print → Save as PDF themselves.
63
+ export async function renderMarkdownToPdf(markdown, outBase, options) {
64
+ const html = await markdownToHtml(markdown, options);
65
+ const browserPath = await findBrowser();
66
+ if (!browserPath) {
67
+ const htmlPath = `${outBase}.html`;
68
+ await writeFile(htmlPath, html, "utf8");
69
+ return {
70
+ ok: false,
71
+ htmlPath,
72
+ reason: "No Chrome, Edge, Chromium, or Brave install was found. Install one, or set CAREER_MCP_CHROME_PATH to your browser's executable. " +
73
+ `In the meantime, open ${htmlPath} in any browser and use Print → Save as PDF.`,
74
+ };
75
+ }
76
+ const browser = await puppeteer.launch({
77
+ executablePath: browserPath,
78
+ headless: true,
79
+ // Chrome refuses to start as root (common in containers/CI) without --no-sandbox.
80
+ args: process.getuid?.() === 0 ? ["--no-sandbox", "--disable-dev-shm-usage"] : [],
81
+ });
82
+ try {
83
+ const page = await browser.newPage();
84
+ await page.setContent(html, { waitUntil: "load" });
85
+ await page.evaluateHandle("document.fonts.ready");
86
+ const pdfPath = `${outBase}.pdf`;
87
+ await page.pdf({ path: pdfPath, printBackground: true, preferCSSPageSize: true });
88
+ return { ok: true, pdfPath };
89
+ }
90
+ finally {
91
+ await browser.close();
92
+ }
93
+ }
@@ -0,0 +1,445 @@
1
+ export const THEME_NAMES = ["navy", "minimal", "classic"];
2
+ // Page-break rules shared by every theme: keep headings attached to what follows them.
3
+ export const SHARED_CSS = `
4
+ * { box-sizing: border-box; }
5
+
6
+ h2, h3, h4 {
7
+ page-break-after: avoid;
8
+ break-after: avoid;
9
+ }
10
+
11
+ h2 + p, h2 + ul, h2 + table,
12
+ h3 + p, h3 + ul,
13
+ h4 + p, h4 + ul {
14
+ page-break-before: avoid;
15
+ break-before: avoid;
16
+ }
17
+
18
+ li, p {
19
+ orphans: 3;
20
+ widows: 3;
21
+ }
22
+
23
+ blockquote, table {
24
+ page-break-inside: avoid;
25
+ break-inside: avoid;
26
+ }
27
+
28
+ thead { display: none; }
29
+
30
+ /* Cover letters reuse the theme but read as prose: roomier paragraphs. */
31
+ body.letter { line-height: 1.55; }
32
+ body.letter p { margin: 0 0 10pt 0; }
33
+ `;
34
+ // The original render-pdf.sh look: Inter, deep navy accents, tinted impact callouts.
35
+ const navy = {
36
+ font: {
37
+ family: "Inter",
38
+ pkg: "@fontsource/inter",
39
+ filePrefix: "inter",
40
+ weights: [400, 500, 600, 700],
41
+ italicWeights: [400],
42
+ },
43
+ css: `
44
+ body {
45
+ font-family: 'Inter', 'Calibri', 'Helvetica Neue', Arial, sans-serif;
46
+ font-size: 10.5pt;
47
+ color: #111827;
48
+ line-height: 1.45;
49
+ margin: 0;
50
+ }
51
+
52
+ h1 {
53
+ font-size: 21pt;
54
+ font-weight: 700;
55
+ color: #0F172A;
56
+ margin: 0 0 3pt 0;
57
+ letter-spacing: -0.02em;
58
+ line-height: 1.1;
59
+ }
60
+
61
+ h1 + p {
62
+ font-size: 10.5pt;
63
+ font-weight: 500;
64
+ color: #1E3A6E;
65
+ margin: 0 0 2pt 0;
66
+ letter-spacing: 0.01em;
67
+ }
68
+
69
+ h1 + p + p {
70
+ font-size: 9.5pt;
71
+ font-weight: 500;
72
+ color: #334155;
73
+ letter-spacing: 0.02em;
74
+ line-height: 1.7;
75
+ margin: 8pt 0 4pt 0;
76
+ padding-top: 6pt;
77
+ border-top: 0.75px solid #CBD5E1;
78
+ }
79
+
80
+ h1 + p + p a {
81
+ color: #1E3A6E;
82
+ font-weight: 600;
83
+ text-decoration: none;
84
+ }
85
+
86
+ h2 {
87
+ font-size: 9pt;
88
+ font-weight: 600;
89
+ text-transform: uppercase;
90
+ letter-spacing: 0.13em;
91
+ color: #1E3A6E;
92
+ border-bottom: 1.5px solid #1E3A6E;
93
+ padding-bottom: 3pt;
94
+ margin: 17pt 0 7pt 0;
95
+ }
96
+
97
+ h3 {
98
+ font-size: 11.5pt;
99
+ font-weight: 700;
100
+ color: #0F172A;
101
+ margin: 10pt 0 0 0;
102
+ line-height: 1.3;
103
+ }
104
+
105
+ h3 + p {
106
+ font-size: 9.5pt;
107
+ font-style: italic;
108
+ color: #64748B;
109
+ margin: 1pt 0 4pt 0;
110
+ }
111
+
112
+ h3 + p + p {
113
+ font-size: 10pt;
114
+ color: #475569;
115
+ margin: 2pt 0 5pt 0;
116
+ }
117
+
118
+ h4 {
119
+ font-size: 10pt;
120
+ font-weight: 600;
121
+ font-style: italic;
122
+ color: #1E3A6E;
123
+ margin: 8pt 0 3pt 0;
124
+ }
125
+
126
+ p { margin: 2pt 0 4pt 0; }
127
+
128
+ ul {
129
+ margin: 2pt 0 5pt 0;
130
+ padding-left: 15pt;
131
+ list-style-type: disc;
132
+ }
133
+
134
+ li {
135
+ font-size: 10.5pt;
136
+ color: #111827;
137
+ line-height: 1.45;
138
+ margin-bottom: 3pt;
139
+ }
140
+
141
+ strong { font-weight: 600; }
142
+ em { font-style: italic; }
143
+ a { color: #1E3A6E; }
144
+
145
+ hr {
146
+ border: none;
147
+ border-top: 1px solid #E2E8F0;
148
+ margin: 8pt 0;
149
+ }
150
+
151
+ blockquote {
152
+ margin: 6pt 0 4pt 0;
153
+ padding: 6pt 10pt;
154
+ border-left: 3px solid #1E3A6E;
155
+ background: #EFF6FF;
156
+ border-radius: 0 3pt 3pt 0;
157
+ color: #1E293B;
158
+ font-size: 10pt;
159
+ font-weight: 500;
160
+ }
161
+
162
+ blockquote p { margin: 0; }
163
+
164
+ table {
165
+ width: 100%;
166
+ border-collapse: collapse;
167
+ font-size: 10pt;
168
+ margin: 4pt 0 2pt 0;
169
+ }
170
+
171
+ td {
172
+ padding: 4pt 8pt 4pt 0;
173
+ vertical-align: top;
174
+ border-bottom: 0.75px solid #E2E8F0;
175
+ }
176
+
177
+ td:first-child {
178
+ width: 25%;
179
+ font-weight: 600;
180
+ color: #1E3A6E;
181
+ font-size: 9.5pt;
182
+ padding-right: 12pt;
183
+ white-space: nowrap;
184
+ }
185
+
186
+ td:last-child {
187
+ color: #334155;
188
+ font-size: 10pt;
189
+ }
190
+
191
+ tr:last-child td { border-bottom: none; }
192
+ `,
193
+ };
194
+ // Monochrome and compact: IBM Plex Sans, no color, thin rules. Fits more on a page.
195
+ const minimal = {
196
+ font: {
197
+ family: "IBM Plex Sans",
198
+ pkg: "@fontsource/ibm-plex-sans",
199
+ filePrefix: "ibm-plex-sans",
200
+ weights: [400, 500, 600, 700],
201
+ italicWeights: [400],
202
+ },
203
+ css: `
204
+ body {
205
+ font-family: 'IBM Plex Sans', 'Helvetica Neue', Arial, sans-serif;
206
+ font-size: 10pt;
207
+ color: #000000;
208
+ line-height: 1.4;
209
+ margin: 0;
210
+ }
211
+
212
+ h1 {
213
+ font-size: 18pt;
214
+ font-weight: 600;
215
+ margin: 0 0 2pt 0;
216
+ line-height: 1.1;
217
+ }
218
+
219
+ h1 + p {
220
+ font-size: 10pt;
221
+ color: #333333;
222
+ margin: 0 0 2pt 0;
223
+ }
224
+
225
+ h1 + p + p {
226
+ font-size: 9pt;
227
+ color: #333333;
228
+ margin: 0 0 6pt 0;
229
+ }
230
+
231
+ h1 + p + p a {
232
+ color: #000000;
233
+ text-decoration: none;
234
+ }
235
+
236
+ h2 {
237
+ font-size: 9.5pt;
238
+ font-weight: 600;
239
+ text-transform: uppercase;
240
+ letter-spacing: 0.08em;
241
+ border-bottom: 0.75px solid #000000;
242
+ padding-bottom: 2pt;
243
+ margin: 13pt 0 5pt 0;
244
+ }
245
+
246
+ h3 {
247
+ font-size: 10.5pt;
248
+ font-weight: 600;
249
+ margin: 8pt 0 0 0;
250
+ line-height: 1.3;
251
+ }
252
+
253
+ h3 + p {
254
+ font-size: 9pt;
255
+ color: #555555;
256
+ margin: 0 0 3pt 0;
257
+ }
258
+
259
+ h3 + p + p {
260
+ font-size: 9.5pt;
261
+ color: #333333;
262
+ margin: 1pt 0 3pt 0;
263
+ }
264
+
265
+ h4 {
266
+ font-size: 9.5pt;
267
+ font-weight: 600;
268
+ margin: 6pt 0 2pt 0;
269
+ }
270
+
271
+ p { margin: 2pt 0 3pt 0; }
272
+
273
+ ul {
274
+ margin: 1pt 0 4pt 0;
275
+ padding-left: 13pt;
276
+ }
277
+
278
+ li {
279
+ margin-bottom: 2pt;
280
+ }
281
+
282
+ strong { font-weight: 600; }
283
+ a { color: #000000; }
284
+
285
+ hr {
286
+ border: none;
287
+ border-top: 0.75px solid #CCCCCC;
288
+ margin: 6pt 0;
289
+ }
290
+
291
+ blockquote {
292
+ margin: 4pt 0;
293
+ padding: 0 0 0 8pt;
294
+ border-left: 2px solid #000000;
295
+ }
296
+
297
+ blockquote p { margin: 0; }
298
+
299
+ table {
300
+ width: 100%;
301
+ border-collapse: collapse;
302
+ margin: 2pt 0;
303
+ }
304
+
305
+ td {
306
+ padding: 2pt 8pt 2pt 0;
307
+ vertical-align: top;
308
+ }
309
+
310
+ td:first-child {
311
+ width: 22%;
312
+ font-weight: 600;
313
+ white-space: nowrap;
314
+ }
315
+ `,
316
+ };
317
+ // Traditional: Source Serif, centered header, small-caps section rules. Suits finance, law, academia.
318
+ const classic = {
319
+ font: {
320
+ family: "Source Serif 4",
321
+ pkg: "@fontsource/source-serif-4",
322
+ filePrefix: "source-serif-4",
323
+ weights: [400, 600, 700],
324
+ italicWeights: [400],
325
+ },
326
+ css: `
327
+ body {
328
+ font-family: 'Source Serif 4', Georgia, 'Times New Roman', serif;
329
+ font-size: 10.5pt;
330
+ color: #1A1A1A;
331
+ line-height: 1.4;
332
+ margin: 0;
333
+ }
334
+
335
+ h1 {
336
+ font-size: 20pt;
337
+ font-weight: 600;
338
+ text-align: center;
339
+ letter-spacing: 0.04em;
340
+ margin: 0 0 3pt 0;
341
+ line-height: 1.1;
342
+ }
343
+
344
+ h1 + p {
345
+ font-size: 10.5pt;
346
+ font-style: italic;
347
+ text-align: center;
348
+ margin: 0 0 2pt 0;
349
+ }
350
+
351
+ h1 + p + p {
352
+ font-size: 9.5pt;
353
+ text-align: center;
354
+ margin: 0 0 6pt 0;
355
+ padding-bottom: 6pt;
356
+ border-bottom: 1px solid #1A1A1A;
357
+ }
358
+
359
+ h1 + p + p a {
360
+ color: #1A1A1A;
361
+ text-decoration: none;
362
+ }
363
+
364
+ h2 {
365
+ font-size: 11pt;
366
+ font-weight: 600;
367
+ font-variant: small-caps;
368
+ letter-spacing: 0.06em;
369
+ border-bottom: 0.5px solid #1A1A1A;
370
+ padding-bottom: 1pt;
371
+ margin: 14pt 0 6pt 0;
372
+ }
373
+
374
+ h3 {
375
+ font-size: 11pt;
376
+ font-weight: 700;
377
+ margin: 9pt 0 0 0;
378
+ line-height: 1.3;
379
+ }
380
+
381
+ h3 + p {
382
+ font-size: 10pt;
383
+ font-style: italic;
384
+ color: #444444;
385
+ margin: 0 0 3pt 0;
386
+ }
387
+
388
+ h3 + p + p {
389
+ font-size: 10pt;
390
+ margin: 1pt 0 4pt 0;
391
+ }
392
+
393
+ h4 {
394
+ font-size: 10.5pt;
395
+ font-weight: 600;
396
+ font-style: italic;
397
+ margin: 7pt 0 2pt 0;
398
+ }
399
+
400
+ p { margin: 2pt 0 4pt 0; }
401
+
402
+ ul {
403
+ margin: 2pt 0 5pt 0;
404
+ padding-left: 15pt;
405
+ }
406
+
407
+ li {
408
+ margin-bottom: 2pt;
409
+ }
410
+
411
+ strong { font-weight: 600; }
412
+ a { color: #1A1A1A; }
413
+
414
+ hr {
415
+ border: none;
416
+ border-top: 0.5px solid #1A1A1A;
417
+ margin: 8pt 0;
418
+ }
419
+
420
+ blockquote {
421
+ margin: 5pt 16pt;
422
+ font-style: italic;
423
+ }
424
+
425
+ blockquote p { margin: 0; }
426
+
427
+ table {
428
+ width: 100%;
429
+ border-collapse: collapse;
430
+ margin: 3pt 0;
431
+ }
432
+
433
+ td {
434
+ padding: 2pt 10pt 2pt 0;
435
+ vertical-align: top;
436
+ }
437
+
438
+ td:first-child {
439
+ width: 25%;
440
+ font-weight: 600;
441
+ white-space: nowrap;
442
+ }
443
+ `,
444
+ };
445
+ export const THEMES = { navy, minimal, classic };
package/dist/storage.js CHANGED
@@ -142,6 +142,13 @@ export async function saveOutput(slug, type, content) {
142
142
  await writeFile(filePath, content, "utf8");
143
143
  return filePath;
144
144
  }
145
+ // Path without extension, so renderers can write <base>.pdf / <base>.html next to <base>.md.
146
+ export function outputBasePath(slug, type) {
147
+ return path.join(APPLICATIONS_DIR, slug, "outputs", type);
148
+ }
149
+ export function baseResumeOutputBasePath() {
150
+ return path.join(DATA_DIR, "resume");
151
+ }
145
152
  export async function getOutput(slug, type) {
146
153
  const filePath = path.join(APPLICATIONS_DIR, slug, "outputs", `${type}.md`);
147
154
  if (!(await exists(filePath)))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sindhujaks/career-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "MCP server for resume/JD match analysis, tailored resumes and cover letters, a structured career-story interview, and LinkedIn About drafting. Local-first — your data never leaves your machine.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -45,9 +45,14 @@
45
45
  "access": "public"
46
46
  },
47
47
  "dependencies": {
48
+ "@fontsource/ibm-plex-sans": "^5.3.0",
49
+ "@fontsource/inter": "^5.3.0",
50
+ "@fontsource/source-serif-4": "^5.3.1",
48
51
  "@modelcontextprotocol/sdk": "^1.0.0",
49
52
  "dotenv": "^16.4.0",
50
- "pdf-parse": "^2.4.5"
53
+ "marked": "^18.1.2",
54
+ "pdf-parse": "^2.4.5",
55
+ "puppeteer-core": "^25.16.0"
51
56
  },
52
57
  "devDependencies": {
53
58
  "@types/node": "^22.0.0",