@joana412/job-tracker-mcp 1.0.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.
Files changed (3) hide show
  1. package/README.md +50 -0
  2. package/package.json +26 -0
  3. package/server.js +142 -0
package/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # job-tracker-mcp
2
+
3
+ A small MCP server that lets an AI agent (Claude Code, Claude Desktop,
4
+ Cursor, etc.) keep track of my job applications.
5
+
6
+ ## Why I built it
7
+
8
+ I was applying to a lot of jobs and tracking them in my head and in a
9
+ messy spreadsheet. I was already pasting job postings into Claude to
10
+ tailor my resume, so I wanted Claude to also remember what I applied to,
11
+ update the status when I heard back, and tell me which applications
12
+ needed a follow-up.
13
+
14
+ A chat alone can't do that: it forgets everything between conversations,
15
+ and it is unreliable at date maths ("was that 9 days ago or 12?"). This
16
+ server gives the agent a small, permanent store and does the date
17
+ calculation for it.
18
+
19
+ ## Tools
20
+
21
+ | Tool | What it does |
22
+ |---|---|
23
+ | `add_application` | Save a job: company, role, location, link, status, notes. |
24
+ | `list_applications` | Show saved jobs, newest first, with `daysSinceUpdate`. Filter by status or company. |
25
+ | `update_application` | Change the status (e.g. `interviewing`, `rejected`) and add dated notes. |
26
+
27
+ Statuses: `saved`, `applied`, `interviewing`, `offer`, `rejected`, `withdrawn`.
28
+
29
+ Data is stored in `~/.job-applications.json`. Set `JOB_TRACKER_FILE` to use
30
+ a different file.
31
+
32
+ ## Setup
33
+
34
+ ```bash
35
+ git clone https://github.com/joanapie/job-tracker-mcp.git
36
+ cd job-tracker-mcp
37
+ npm install
38
+ ```
39
+
40
+ Add it to Claude Code:
41
+
42
+ ```bash
43
+ claude mcp add job-tracker -- node /absolute/path/to/job-tracker-mcp/server.js
44
+ ```
45
+
46
+ Things you can then say:
47
+
48
+ - *"Here's a posting I just applied to: [paste]. Add it."*
49
+ - *"Which applications have had no update for more than 10 days?"*
50
+ - *"Konrad invited me to a phone screen on Friday."*
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@joana412/job-tracker-mcp",
3
+ "version": "1.0.0",
4
+ "description": "MCP server that lets an AI agent track my job applications",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/joanapie/job-tracker-mcp"
8
+ },
9
+ "scripts": {
10
+ "test": "node --test test/*.test.js",
11
+ "start": "node server.js"
12
+ },
13
+ "files": ["server.js", "README.md"],
14
+ "license": "MIT",
15
+ "type": "module",
16
+ "dependencies": {
17
+ "@modelcontextprotocol/sdk": "^1.30.0",
18
+ "zod": "^4.6.5"
19
+ },
20
+ "engines": {
21
+ "node": ">=20"
22
+ },
23
+ "bin": {
24
+ "job-tracker-mcp": "server.js"
25
+ }
26
+ }
package/server.js ADDED
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env node
2
+ // MCP server that lets an AI agent keep track of my job applications.
3
+ // Data is stored in one JSON file on my computer.
4
+ import { readFile, writeFile } from 'node:fs/promises';
5
+ import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
8
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
9
+ import { z } from 'zod';
10
+
11
+ // Where the data lives. Can be changed with an environment variable.
12
+ const DATA_FILE = process.env.JOB_TRACKER_FILE || join(homedir(), '.job-applications.json');
13
+
14
+ const STATUSES = ['saved', 'applied', 'interviewing', 'offer', 'rejected', 'withdrawn'];
15
+
16
+ // ---------- Reading and saving the file ----------
17
+
18
+ async function load() {
19
+ try {
20
+ return JSON.parse(await readFile(DATA_FILE, 'utf8'));
21
+ } catch (err) {
22
+ if (err.code === 'ENOENT') return []; // first run: no file yet
23
+ throw err;
24
+ }
25
+ }
26
+
27
+ async function save(applications) {
28
+ await writeFile(DATA_FILE, JSON.stringify(applications, null, 2));
29
+ }
30
+
31
+ const today = () => new Date().toISOString().slice(0, 10); // e.g. 2026-09-22
32
+
33
+ // Days between a date and today, so the agent never has to do date maths.
34
+ const daysSince = (date) => Math.floor((Date.now() - new Date(date).getTime()) / 86_400_000);
35
+
36
+ const reply = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
37
+ const fail = (message) => ({ isError: true, content: [{ type: 'text', text: message }] });
38
+
39
+ // ---------- The server and its tools ----------
40
+
41
+ const server = new McpServer({ name: 'job-tracker', version: '1.0.0' });
42
+
43
+ server.registerTool(
44
+ 'add_application',
45
+ {
46
+ title: 'Add a job application',
47
+ description:
48
+ 'Record a job the user has applied to or wants to apply to. ' +
49
+ 'If the user pastes a job posting, extract the company, role and location from it. ' +
50
+ 'Call list_applications first to avoid adding the same job twice.',
51
+ inputSchema: {
52
+ company: z.string().describe('Company name'),
53
+ role: z.string().describe('Job title'),
54
+ location: z.string().optional().describe('City, or "Remote"'),
55
+ url: z.string().optional().describe('Link to the posting'),
56
+ status: z.enum(STATUSES).optional().describe('Defaults to "applied"'),
57
+ notes: z.string().optional().describe('Anything worth remembering, e.g. salary range or contact name'),
58
+ },
59
+ },
60
+ async (input) => {
61
+ const applications = await load();
62
+ const application = {
63
+ id: applications.reduce((max, a) => Math.max(max, a.id), 0) + 1,
64
+ company: input.company,
65
+ role: input.role,
66
+ location: input.location ?? '',
67
+ url: input.url ?? '',
68
+ status: input.status ?? 'applied',
69
+ notes: input.notes ?? '',
70
+ dateAdded: today(),
71
+ lastUpdated: today(),
72
+ };
73
+ applications.push(application);
74
+ await save(applications);
75
+ return reply({ added: application });
76
+ },
77
+ );
78
+
79
+ server.registerTool(
80
+ 'list_applications',
81
+ {
82
+ title: 'List job applications',
83
+ description:
84
+ 'Show saved job applications, newest first. Each one includes daysSinceUpdate, ' +
85
+ 'so you can answer questions like "which applications have had no reply for two weeks?". ' +
86
+ 'Optionally filter by status or by company name.',
87
+ inputSchema: {
88
+ status: z.enum(STATUSES).optional(),
89
+ company: z.string().optional().describe('Part of a company name, case-insensitive'),
90
+ },
91
+ annotations: { readOnlyHint: true },
92
+ },
93
+ async ({ status, company }) => {
94
+ let applications = await load();
95
+ if (status) applications = applications.filter((a) => a.status === status);
96
+ if (company) {
97
+ applications = applications.filter((a) => a.company.toLowerCase().includes(company.toLowerCase()));
98
+ }
99
+ const result = applications
100
+ .map((a) => ({ ...a, daysSinceUpdate: daysSince(a.lastUpdated) }))
101
+ .sort((a, b) => b.id - a.id);
102
+ return reply({ count: result.length, applications: result });
103
+ },
104
+ );
105
+
106
+ server.registerTool(
107
+ 'update_application',
108
+ {
109
+ title: 'Update a job application',
110
+ description:
111
+ 'Change the status of an application or add a note, e.g. when the user gets an interview ' +
112
+ 'or a rejection. Can also correct the company, role, location or link. ' +
113
+ 'Use the id from list_applications. New notes are added to existing ones.',
114
+ inputSchema: {
115
+ id: z.number().int().describe('The application id'),
116
+ status: z.enum(STATUSES).optional(),
117
+ note: z.string().optional().describe('Added to the existing notes with today\'s date'),
118
+ company: z.string().optional().describe('New company name'),
119
+ role: z.string().optional().describe('New job title'),
120
+ location: z.string().optional().describe('New location: city, or "Remote"'),
121
+ url: z.string().optional().describe('New link to the posting; an empty string clears it'),
122
+ },
123
+ },
124
+ async ({ id, status, note, company, role, location, url }) => {
125
+ const applications = await load();
126
+ const application = applications.find((a) => a.id === id);
127
+ if (!application) {
128
+ return fail(`No application with id ${id}. Call list_applications to see valid ids.`);
129
+ }
130
+ if (status) application.status = status;
131
+ if (company) application.company = company;
132
+ if (role) application.role = role;
133
+ if (location !== undefined) application.location = location;
134
+ if (url !== undefined) application.url = url;
135
+ if (note) application.notes = [application.notes, `[${today()}] ${note}`].filter(Boolean).join('\n');
136
+ application.lastUpdated = today();
137
+ await save(applications);
138
+ return reply({ updated: application });
139
+ },
140
+ );
141
+
142
+ await server.connect(new StdioServerTransport());