@sindhujaks/career-mcp 0.0.0-stage → 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 ADDED
@@ -0,0 +1,27 @@
1
+ # career-mcp — environment variables
2
+ # Copy this file to .env and fill in your values. Never commit .env.
3
+ #
4
+ # Only needed if you're using the standalone CLI (career-mcp-cli) without an
5
+ # MCP-capable client. The MCP server itself (career-mcp) needs none of this —
6
+ # it relies on the host LLM (Claude Code, Claude Desktop, etc.) to reason.
7
+
8
+ # ─── Optional — where your resume/stories/applications are stored ────────────
9
+ # Defaults to ~/.career-mcp
10
+ CAREER_MCP_DATA_DIR=
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
+
19
+ # ─── Required for the standalone CLI only ─────────────────────────────────────
20
+ # Bring your own key — career-mcp never calls a shared/hosted key, so there's
21
+ # no cost or liability on anyone but you.
22
+ #
23
+ # "groq" — free tier at https://console.groq.com/keys
24
+ # "gemini" — free tier at https://aistudio.google.com/apikey
25
+ CAREER_MCP_LLM_PROVIDER=groq
26
+ CAREER_MCP_API_KEY=
27
+ # CAREER_MCP_LLM_MODEL= # optional override, defaults per provider
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sindhuja Kalisrinivasan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,109 @@
1
- # Temporary Holding Version
1
+ # career-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+
5
+ Local-first. Your resume and stories live in a folder on your machine (`~/.career-mcp` by default) — nothing is uploaded anywhere.
6
+
7
+ ## How it works
8
+
9
+ The MCP server itself doesn't call any LLM. It's a data layer: it stores your resume, job descriptions, voice profile, and stories, and hands them back as context. The actual reasoning — match analysis, tailoring, writing — is done by whatever MCP-capable client you're using (Claude Code, Claude Desktop, etc.), the same way any tool-using conversation works.
10
+
11
+ If you don't have an MCP client, the bundled CLI (`career-mcp-cli`) does the same job standalone, using your own Groq or Gemini API key (both have usable free tiers) to do the reasoning itself.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install -g @sindhujaks/career-mcp
17
+ ```
18
+
19
+ ## Use with an MCP client (recommended)
20
+
21
+ Add to your client's MCP config (e.g. `.mcp.json` for Claude Code):
22
+
23
+ ```json
24
+ {
25
+ "mcpServers": {
26
+ "career-mcp": {
27
+ "command": "career-mcp"
28
+ }
29
+ }
30
+ }
31
+ ```
32
+
33
+ Then, in conversation: save your resume, save a JD, and ask your client to run a match analysis, draft a tailored resume, write a cover letter, or draft a LinkedIn About. Say "let's capture a career story" to run the guided interview.
34
+
35
+ ### Tools
36
+
37
+ | Tool | Purpose |
38
+ |---|---|
39
+ | `save_resume` / `get_resume` | Store/retrieve your base resume |
40
+ | `save_voice_profile` / `get_voice_profile` | Store tone/style guidance so generated content sounds like you |
41
+ | `save_jd` | Store a job description, sets it as the active application |
42
+ | `get_active_application` | Bundles resume + JD + voice profile + relevant stories as context |
43
+ | `list_applications` | List all stored applications |
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`) |
46
+ | `save_story` / `query_stories` | Save/retrieve career stories, filterable by tag |
47
+ | `start_story_interview` / `answer_story_question` | Guided, protocol-driven interview to capture one new story |
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
+
77
+ ## Use standalone (no MCP client)
78
+
79
+ ```bash
80
+ cp .env.example .env # fill in CAREER_MCP_API_KEY (free Groq or Gemini key)
81
+
82
+ career-mcp-cli set-resume --file resume.txt
83
+ career-mcp-cli set-jd --company Acme --role "Senior Engineer" --file jd.txt
84
+ career-mcp-cli analyze
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
88
+ career-mcp-cli about
89
+ career-mcp-cli interview # guided story capture
90
+ ```
91
+
92
+ ## Data layout
93
+
94
+ ```
95
+ ~/.career-mcp/
96
+ ├── resume.json
97
+ ├── voice_profile.md
98
+ ├── stories/<slug>.md
99
+ └── applications/<company-role>/
100
+ ├── jd.json
101
+ └── outputs/{match_analysis,tailored_resume,cover_letter,linkedin_about}.md
102
+ (+ tailored_resume.pdf / cover_letter.pdf once exported)
103
+ ```
104
+
105
+ Override the location with `CAREER_MCP_DATA_DIR`.
106
+
107
+ ## License
108
+
109
+ MIT
package/dist/cli.js ADDED
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env node
2
+ import * as readline from "node:readline/promises";
3
+ import { stdin, stdout } from "node:process";
4
+ import "dotenv/config";
5
+ import * as storage from "./storage.js";
6
+ import { startStoryInterview, answerStoryQuestion } from "./interview.js";
7
+ import { callLLM } from "./llm-fallback.js";
8
+ import { exportPdf, describeResult } from "./export.js";
9
+ import { RESUME_MARKDOWN_GUIDE } from "./render/markdown.js";
10
+ const SYSTEM_PROMPT = "You are a career writing assistant. Write in a clear, direct, honest voice — " +
11
+ "no buzzwords, no AI-sounding phrases ('in today's fast-paced world', 'as we navigate'), " +
12
+ "no fake inspiration. Prefer specific, grounded claims over impressive-sounding generalities. " +
13
+ "Never fabricate experience that isn't in the provided resume or stories.";
14
+ async function readArgFile(flagValue) {
15
+ if (!flagValue)
16
+ return undefined;
17
+ return storage.readTextFile(flagValue);
18
+ }
19
+ function parseFlags(argv) {
20
+ const flags = {};
21
+ for (let i = 0; i < argv.length; i++) {
22
+ if (argv[i].startsWith("--")) {
23
+ const key = argv[i].slice(2);
24
+ const value = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : "true";
25
+ flags[key] = value;
26
+ }
27
+ }
28
+ return flags;
29
+ }
30
+ async function buildContext() {
31
+ const [resume, voice, application] = await Promise.all([
32
+ storage.getResume(),
33
+ storage.getVoiceProfile(),
34
+ storage.getActiveApplication(),
35
+ ]);
36
+ if (!application)
37
+ throw new Error("No active application. Run `career-mcp-cli set-jd` first.");
38
+ const stories = await storage.queryStories();
39
+ return { resume, voice, application, stories };
40
+ }
41
+ function contextToPrompt(ctx) {
42
+ return [
43
+ `Job description (${ctx.application.company} — ${ctx.application.role}):`,
44
+ ctx.application.jdText,
45
+ "",
46
+ "Resume:",
47
+ ctx.resume?.rawText ?? "(none provided)",
48
+ "",
49
+ "Voice profile:",
50
+ ctx.voice ?? "(none provided — default to clear, direct, honest)",
51
+ ctx.stories.length > 0 ? `\nCareer stories (use if relevant):\n${ctx.stories.map((s) => `${s.title}: ${s.content}`).join("\n\n")}` : "",
52
+ ].join("\n");
53
+ }
54
+ async function cmdSetResume(flags) {
55
+ const text = await readArgFile(flags.file);
56
+ if (!text)
57
+ throw new Error("Usage: career-mcp-cli set-resume --file <path>");
58
+ await storage.saveResume(text);
59
+ console.log("Resume saved.");
60
+ }
61
+ async function cmdSetJD(flags) {
62
+ const text = await readArgFile(flags.file);
63
+ if (!text || !flags.company || !flags.role) {
64
+ throw new Error("Usage: career-mcp-cli set-jd --company <name> --role <title> --file <path>");
65
+ }
66
+ const application = await storage.saveJD(flags.company, flags.role, text);
67
+ console.log(`Saved JD for ${flags.company} — ${flags.role} (${application.slug}), set as active.`);
68
+ }
69
+ async function cmdGenerate(outputType, instruction) {
70
+ const ctx = await buildContext();
71
+ const prompt = `${instruction}\n\n${contextToPrompt(ctx)}`;
72
+ const result = await callLLM(SYSTEM_PROMPT, prompt);
73
+ console.log(result);
74
+ await storage.saveOutput(ctx.application.slug, outputType, result);
75
+ console.log(`\n(saved to ~/.career-mcp/applications/${ctx.application.slug}/outputs/${outputType}.md)`);
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
+ }
86
+ async function cmdInterview() {
87
+ const rl = readline.createInterface({ input: stdin, output: stdout });
88
+ let step = startStoryInterview();
89
+ console.log(`Let's capture a story. ${step.total_questions} quick questions.\n`);
90
+ while (step.status === "in_progress") {
91
+ const answer = await rl.question(`${step.question}\n> `);
92
+ step = await answerStoryQuestion(step.session_id, answer);
93
+ console.log("");
94
+ }
95
+ console.log(`Saved: "${step.story?.title}" [${step.story?.tags.join(", ")}]`);
96
+ rl.close();
97
+ }
98
+ async function main() {
99
+ const [command, ...rest] = process.argv.slice(2);
100
+ const flags = parseFlags(rest);
101
+ switch (command) {
102
+ case "set-resume":
103
+ return cmdSetResume(flags);
104
+ case "set-jd":
105
+ return cmdSetJD(flags);
106
+ case "interview":
107
+ return cmdInterview();
108
+ case "analyze":
109
+ return cmdGenerate("match_analysis", "Analyze how well this resume matches this job description. Call out genuine overlaps and honest gaps — no keyword stuffing.");
110
+ case "resume":
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");
113
+ case "cover-letter":
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);
120
+ case "about":
121
+ return cmdGenerate("linkedin_about", "Write a LinkedIn About section based on this resume and these stories, in the voice profile's tone.");
122
+ default:
123
+ console.log([
124
+ "career-mcp-cli — standalone mode (no MCP client needed)",
125
+ "Requires CAREER_MCP_API_KEY in your environment (Groq or Gemini free tier). See .env.example.",
126
+ "",
127
+ "Commands:",
128
+ " set-resume --file <path>",
129
+ " set-jd --company <name> --role <title> --file <path>",
130
+ " interview",
131
+ " analyze",
132
+ " resume (also writes a PDF)",
133
+ " cover-letter [--pdf]",
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.",
139
+ ].join("\n"));
140
+ }
141
+ }
142
+ main().catch((err) => {
143
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
144
+ process.exit(1);
145
+ });
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 ADDED
@@ -0,0 +1,270 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
+ import * as storage from "./storage.js";
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";
10
+ const server = new Server({ name: "career-mcp", version: "1.0.0" }, { capabilities: { tools: {} } });
11
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
12
+ tools: [
13
+ {
14
+ name: "save_resume",
15
+ description: "Store the user's base resume, either as raw text or by reading a local file. Overwrites any previously stored resume. Provide exactly one of resume_text or resume_path.",
16
+ inputSchema: {
17
+ type: "object",
18
+ properties: {
19
+ resume_text: { type: "string", description: "The full resume content" },
20
+ resume_path: { type: "string", description: "Absolute path to a .pdf, .txt, or .md resume file to read instead of passing raw text" },
21
+ },
22
+ },
23
+ },
24
+ {
25
+ name: "get_resume",
26
+ description: "Retrieve the stored base resume.",
27
+ inputSchema: { type: "object", properties: {} },
28
+ },
29
+ {
30
+ name: "save_voice_profile",
31
+ description: "Store a description of the user's writing voice/tone (e.g. tone attributes, phrases to avoid, sample posts). Used to keep generated cover letters and LinkedIn About sections sounding like the user, not generic AI copy.",
32
+ inputSchema: {
33
+ type: "object",
34
+ properties: {
35
+ voice_text: { type: "string", description: "Description of tone, style, and things to avoid" },
36
+ },
37
+ required: ["voice_text"],
38
+ },
39
+ },
40
+ {
41
+ name: "get_voice_profile",
42
+ description: "Retrieve the stored voice profile, if one has been saved.",
43
+ inputSchema: { type: "object", properties: {} },
44
+ },
45
+ {
46
+ name: "save_jd",
47
+ description: "Store a job description for a company/role and mark it as the active application. Creates a new application record. Provide exactly one of jd_text or jd_path.",
48
+ inputSchema: {
49
+ type: "object",
50
+ properties: {
51
+ company: { type: "string" },
52
+ role: { type: "string" },
53
+ jd_text: { type: "string", description: "The full job description text" },
54
+ jd_path: { type: "string", description: "Absolute path to a .pdf, .txt, or .md job description file to read instead of passing raw text" },
55
+ },
56
+ required: ["company", "role"],
57
+ },
58
+ },
59
+ {
60
+ name: "get_active_application",
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}`,
62
+ inputSchema: { type: "object", properties: {} },
63
+ },
64
+ {
65
+ name: "list_applications",
66
+ description: "List all stored applications (company, role, created date).",
67
+ inputSchema: { type: "object", properties: {} },
68
+ },
69
+ {
70
+ name: "save_output",
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.",
72
+ inputSchema: {
73
+ type: "object",
74
+ properties: {
75
+ output_type: {
76
+ type: "string",
77
+ enum: ["match_analysis", "tailored_resume", "cover_letter", "linkedin_about"],
78
+ },
79
+ content: { type: "string" },
80
+ application_slug: { type: "string", description: "Optional — defaults to the active application" },
81
+ },
82
+ required: ["output_type", "content"],
83
+ },
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
+ },
102
+ {
103
+ name: "save_story",
104
+ description: "Save a career story directly (title, tags, content) without running the interview flow. Prefer start_story_interview for a guided capture.",
105
+ inputSchema: {
106
+ type: "object",
107
+ properties: {
108
+ title: { type: "string" },
109
+ tags: { type: "array", items: { type: "string" } },
110
+ content: { type: "string" },
111
+ },
112
+ required: ["title", "tags", "content"],
113
+ },
114
+ },
115
+ {
116
+ name: "query_stories",
117
+ description: "Retrieve saved career stories, optionally filtered by tag (e.g. leadership, conflict, failure-recovery, technical-deep-dive). Use to find a story relevant to a JD requirement, an interview-prep question, or a LinkedIn post idea.",
118
+ inputSchema: {
119
+ type: "object",
120
+ properties: {
121
+ tags: { type: "array", items: { type: "string" }, description: "Optional tag filter" },
122
+ },
123
+ },
124
+ },
125
+ {
126
+ name: "start_story_interview",
127
+ description: "Begin a structured interview to capture one new career story. Returns the first question and a session_id. Keep calling answer_story_question with that session_id, relaying each returned question to the user verbatim, until status is 'complete' — the story is then auto-saved.",
128
+ inputSchema: { type: "object", properties: {} },
129
+ },
130
+ {
131
+ name: "answer_story_question",
132
+ description: "Submit the user's answer to the current interview question and get the next one (or the completed, saved story).",
133
+ inputSchema: {
134
+ type: "object",
135
+ properties: {
136
+ session_id: { type: "string" },
137
+ answer: { type: "string" },
138
+ },
139
+ required: ["session_id", "answer"],
140
+ },
141
+ },
142
+ ],
143
+ }));
144
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
145
+ const { name, arguments: args } = request.params;
146
+ try {
147
+ switch (name) {
148
+ case "save_resume": {
149
+ const { resume_text, resume_path } = args;
150
+ const text = resume_text ?? (resume_path ? await storage.readTextFile(resume_path) : undefined);
151
+ if (!text)
152
+ throw new Error("Provide either resume_text or resume_path.");
153
+ await storage.saveResume(text);
154
+ return textResult("Resume saved.");
155
+ }
156
+ case "get_resume": {
157
+ const resume = await storage.getResume();
158
+ return textResult(resume?.rawText ? resume.rawText : "No resume stored yet. Use save_resume first.");
159
+ }
160
+ case "save_voice_profile": {
161
+ const { voice_text } = args;
162
+ await storage.saveVoiceProfile(voice_text);
163
+ return textResult("Voice profile saved.");
164
+ }
165
+ case "get_voice_profile": {
166
+ const voice = await storage.getVoiceProfile();
167
+ return textResult(voice ?? "No voice profile stored yet.");
168
+ }
169
+ case "save_jd": {
170
+ const { company, role, jd_text, jd_path } = args;
171
+ const text = jd_text ?? (jd_path ? await storage.readTextFile(jd_path) : undefined);
172
+ if (!text)
173
+ throw new Error("Provide either jd_text or jd_path.");
174
+ const application = await storage.saveJD(company, role, text);
175
+ return textResult(`Saved job description for ${company} — ${role}. Set as the active application (slug: ${application.slug}).`);
176
+ }
177
+ case "get_active_application": {
178
+ const application = await storage.getActiveApplication();
179
+ if (!application)
180
+ return textResult("No active application. Use save_jd first.");
181
+ const [resume, voice, stories] = await Promise.all([
182
+ storage.getResume(),
183
+ storage.getVoiceProfile(),
184
+ storage.queryStories(),
185
+ ]);
186
+ const relevantStories = stories.filter((s) => application.jdText.toLowerCase().split(/\W+/).some((word) => word.length > 4 && s.content.toLowerCase().includes(word)));
187
+ const sections = [
188
+ `## Active application: ${application.company} — ${application.role}`,
189
+ "",
190
+ "### Job description",
191
+ application.jdText,
192
+ "",
193
+ "### Resume",
194
+ resume?.rawText ? resume.rawText : "(none stored)",
195
+ "",
196
+ "### Voice profile",
197
+ voice ?? "(none stored — write in a clear, direct, honest tone; avoid buzzwords and AI-sounding phrases)",
198
+ ];
199
+ if (relevantStories.length > 0) {
200
+ sections.push("", "### Potentially relevant stories", ...relevantStories.map((s) => `**${s.title}** [${s.tags.join(", ")}]\n${s.content}`));
201
+ }
202
+ return textResult(sections.join("\n"));
203
+ }
204
+ case "list_applications": {
205
+ const applications = await storage.listApplications();
206
+ if (applications.length === 0)
207
+ return textResult("No applications stored yet.");
208
+ return textResult(applications.map((a) => `- ${a.company} — ${a.role} (${a.slug}, created ${a.createdAt})`).join("\n"));
209
+ }
210
+ case "save_output": {
211
+ const { output_type, content, application_slug } = args;
212
+ let slug = application_slug;
213
+ if (!slug) {
214
+ const active = await storage.getActiveApplication();
215
+ if (!active)
216
+ throw new Error("No active application and no application_slug provided.");
217
+ slug = active.slug;
218
+ }
219
+ const filePath = await storage.saveOutput(slug, output_type, content);
220
+ return textResult(`Saved ${output_type} to ${filePath}`);
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
+ }
227
+ case "save_story": {
228
+ const { title, tags, content } = args;
229
+ const story = await storage.saveStory(title, tags, content);
230
+ return textResult(`Story saved: ${story.title} (${story.slug})`);
231
+ }
232
+ case "query_stories": {
233
+ const { tags } = args;
234
+ const stories = await storage.queryStories(tags);
235
+ if (stories.length === 0)
236
+ return textResult("No matching stories found.");
237
+ return textResult(stories.map((s) => `**${s.title}** [${s.tags.join(", ")}]\n${s.content}`).join("\n\n---\n\n"));
238
+ }
239
+ case "start_story_interview": {
240
+ const step = startStoryInterview();
241
+ return textResult(`Question 1 of ${step.total_questions}:\n${step.question}\n\n(session_id: ${step.session_id})`);
242
+ }
243
+ case "answer_story_question": {
244
+ const { session_id, answer } = args;
245
+ const step = await answerStoryQuestion(session_id, answer);
246
+ if (step.status === "complete") {
247
+ return textResult(`Story complete and saved: "${step.story?.title}" (tags: ${step.story?.tags.join(", ")})`);
248
+ }
249
+ return textResult(`Question ${step.question_number} of ${step.total_questions}:\n${step.question}\n\n(session_id: ${step.session_id})`);
250
+ }
251
+ default:
252
+ throw new Error(`Unknown tool: ${name}`);
253
+ }
254
+ }
255
+ catch (err) {
256
+ const message = err instanceof Error ? err.message : String(err);
257
+ return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
258
+ }
259
+ });
260
+ function textResult(text) {
261
+ return { content: [{ type: "text", text }] };
262
+ }
263
+ async function main() {
264
+ const transport = new StdioServerTransport();
265
+ await server.connect(transport);
266
+ }
267
+ main().catch((err) => {
268
+ console.error("career-mcp server error:", err);
269
+ process.exit(1);
270
+ });