Skip to main content
Glama
Kanavpreet-Singh

JobTrack MCP Server

README.md
# JobTrack MCP — Job Application Tracker MCP Server

An MCP (Model Context Protocol) server for managing your job search — companies, roles, statuses, stages, dates, notes, and milestone reminders (OAs, interviews, deadlines, follow-ups) — exposed as tools any MCP-compatible client can call. Ask your assistant to log an application, check what's coming up, or mark something done, in plain English.

Multi-user, Postgres-backed, API-key authenticated, and fully containerized — clone it, run one command, and connect it to Claude Desktop.

## See it in action

<table>
<tr><td width="33%" valign="top">

**Creating an application**

Claude asks clarifying questions, then calls the `create_job_application` tool.

![Creating a job application entry via natural language](images/01-create-job-application.png)

</td><td width="33%" valign="top">

**Natural-language dates**

"next week on Monday" and "I applied yesterday" get resolved and checked against existing records before creating a new one.

![Adding an OA entry with relative dates, after checking existing applications](images/02-natural-language-oa-entry.png)

</td><td width="33%" valign="top">

**Upcoming reminders**

`list_upcoming_events` pulls every not-yet-completed milestone across all applications, soonest first.

![Listing all upcoming interviews and OAs](images/03-list-upcoming-events.png)

</td></tr>
</table>

## Quickstart (Docker, one command)

Requires only [Docker](https://www.docker.com/products/docker-desktop/) — no Node.js needed on your machine.

```bash
git clone https://github.com/Kanavpreet-Singh/JobTrack-MCP-Server.git
cd JobTrack-MCP-Server
docker compose up -d
```

That's it — this builds the image, starts a bundled Postgres, applies the database schema automatically on first boot, and starts the server at `http://localhost:3000/mcp`. Postgres is published on host port `5433` (not `5432`) so it won't clash with any Postgres you might already have running locally; the app talks to it over Docker's internal network on the standard port regardless.

Confirm it's up:

```bash
curl http://localhost:3000/healthz
# => {"status":"ok"}
```

Tear it down with `docker compose down` (add `-v` to also wipe the database volume).

## Connect it to Claude Desktop

**1. Register an account** (the server has no default user — anyone connecting needs their own API key):

```bash
curl -s -X POST http://localhost:3000/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"you@example.com","password":"a strong passphrase"}'
```

Copy the `apiKey` from the response — it's shown once and can't be recovered later, only rotated (see [Authentication](#authentication)).

**2. Edit your Claude Desktop config** — `%APPDATA%\Claude\claude_desktop_config.json` on Windows, `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS. Add a `jobtrack` entry to `mcpServers` (keep any other servers already there):

```json
{
  "mcpServers": {
    "jobtrack": {
      "command": "npx",
      "args": [
        "-y", "mcp-remote", "http://localhost:3000/mcp",
        "--transport", "http-only",
        "--header", "Authorization:${AUTH_HEADER}"
      ],
      "env": { "AUTH_HEADER": "Bearer YOUR_API_KEY" }
    }
  }
}
```

Two details that matter here:
- **`mcp-remote`** bridges Claude Desktop's local stdio transport to this server's HTTP endpoint, since Desktop doesn't speak Streamable HTTP natively yet.
- **`--transport http-only`** is required. `mcp-remote`'s default strategy probes for OAuth support and, on certain failures during that probe, falls back to an SSE transport this server doesn't implement (it's intentionally stateless and POST-only). This flag skips that fallback and connects directly with the plain `Authorization` header.

**3. Fully quit and reopen Claude Desktop** (not just close the window — it only reads this file on startup). Check **Settings → Connectors** for `jobtrack` showing as connected with 10 tools, then just talk to it: *"log an application for a Backend Engineer role at Acme Corp, I applied today"*.

## Tools

| Tool | Description |
|---|---|
| `create_job_application` | Create a new job application (optionally with initial milestone dates). |
| `get_job_application` | Fetch one job application by id. |
| `update_job_application` | Partially update a job application by id (only supplied fields change). |
| `delete_job_application` | Permanently delete a job application by id. |
| `list_job_applications` | List/filter (`status`, `company`, `role`) or free-text search (`query`) job applications, with `limit`/`offset` pagination. |
| `add_important_date` | Add a milestone date (OA deadline, interview, follow-up, ...) to an application. |
| `remove_important_date` | Remove a milestone date. |
| `mark_important_date_completed` | Mark a milestone date done (or not). |
| `list_upcoming_events` | Not-yet-completed dates due within a lookahead window (default 14 days), across all your applications. |
| `list_overdue_actions` | Not-yet-completed dates that have already passed, across all your applications. |

Every tool returns both a human-readable `content` block and a typed `structuredContent` object. Not-found errors surface as tool-level errors (`isError: true`) rather than protocol errors, so an LLM client can see and react to them.

## Data model

Each job application is a single record, scoped to the authenticated user:

| Field | Type | Notes |
|---|---|---|
| `id` | UUID | server-generated |
| `company` | string | required |
| `role` | string | required |
| `status` | enum | `applied`, `OA`, `interview`, `offer`, `rejected`, `withdrawn` — defaults to `applied` |
| `stage` | string (optional) | free-text detail within a status, e.g. `"Onsite Round 2"` |
| `applicationDate` | `YYYY-MM-DD` | required |
| `importantDates` | `{ id, label, date, completed }[]` | milestone dates; managed via the dedicated tools above, not bulk-edited through `update_job_application` |
| `notes` | string (optional) | |
| `createdAt` / `updatedAt` | ISO 8601 timestamp | server-set |

## Authentication

Every `/mcp` request must carry `Authorization: Bearer <api key>`. There is no anonymous access — all data is scoped per user.

- **`POST /auth/register`** — `{ email, password }` → creates an account and returns a freshly generated API key.
- **`POST /auth/login`** — `{ email, password }` → verifies the password and issues a **new** API key, invalidating the previous one. This is the recovery path if a key is lost.

Both endpoints are rate-limited (10 requests / 15 min / IP) against brute-forcing.

## Stack

- **@modelcontextprotocol/sdk** `^1.29.0` — official TypeScript MCP SDK
- **TypeScript** + **Node.js** (ESM, `>=18`)
- **Zod v4** — input validation and self-documenting tool schemas
- **Express 5** — HTTP transport (via the SDK's `createMcpExpressApp`, which adds DNS-rebinding protection out of the box)
- **Streamable HTTP**, stateless mode — a fresh MCP server + repository per request, scoped to the caller's user id
- **PostgreSQL** + **Drizzle ORM** — persistent, per-user-isolated storage; migrations applied automatically on container startup
- **API-key Bearer auth** — `@node-rs/argon2` for password hashing, SHA-256-hashed API keys, `express-rate-limit` on the auth endpoints
- **pino** — structured logging
- **vitest** — unit + integration tests
- **Docker** — multi-stage build, non-root runtime user, health-checked

## Running without Docker

Useful if you're developing on the server itself. Requires Node.js `>=18` and a Postgres database — a free [Neon](https://neon.tech) project works well if you don't want to run one locally.

```bash
npm install
cp .env.example .env   # set DATABASE_URL
npm run db:migrate     # applies drizzle/*.sql to DATABASE_URL
npm run dev            # tsx watch, serves http://localhost:3000/mcp
```

Other scripts:

```bash
npm run build && npm start   # compiled production build (dist/server.js)
npm test                     # unit + integration tests (vitest) — no DB needed, uses an in-memory repo
npm run coverage              # tests with coverage report
npm run db:generate           # generate a new migration after editing src/db/schema.ts
npm run db:migrate            # apply pending migrations to DATABASE_URL
npm run verify-http           # smoke test against a running, DB-backed server
```

## Verifying it works

**1. Automated tests** (no DB needed):

```bash
npm test
```

**2. Raw HTTP smoke test** (needs a running server — Docker or `npm run dev`):

```bash
npm run verify-http
```

Registers a throwaway account, then exercises `/healthz`, the 401-without-auth case, `tools/list`, and the full create → get/update/list → important-date lifecycle → delete flow over real HTTP.

**3. MCP Inspector** (interactive):

```bash
npx @modelcontextprotocol/inspector
```

Choose transport **"Streamable HTTP"**, connect to `http://localhost:3000/mcp`, and add header `Authorization: Bearer <api key>` (register one via curl first — see [Authentication](#authentication)).

## Connecting other MCP clients

Any client with native Streamable HTTP support can point directly at `http://localhost:3000/mcp` with header `Authorization: Bearer <your api key>` — no `mcp-remote` bridge needed.

For a client that manages per-user credentials for multiple backend services (GitHub PATs, other bearer-token integrations, etc.), register one JobTrack account per end user and store their `apiKey` the same way — the server has no notion of your client's own auth scheme, only that a valid JobTrack API key is presented.

## Project structure

```
src/
  server.ts                     Express app: /healthz, /auth/*, /mcp (authMiddleware + stateless Streamable HTTP), entrypoint
  mcpServer.ts                  Builds a configured McpServer (transport-agnostic)
  config.ts                     Env loading/validation (PORT, NODE_ENV, LOG_LEVEL, DATABASE_URL)
  logger.ts                     pino logger
  errors.ts                     NotFoundError + tool-error mapping
  schemas/jobApplication.ts     Zod schemas (JobApplication, ImportantDate, create/update inputs)
  db/schema.ts                  Drizzle table definitions (users, job_applications, important_dates)
  db/client.ts                  pg Pool + Drizzle instance (fails fast if DATABASE_URL is unset)
  db/migrate.ts                 Programmatic migration runner (used by the container's entrypoint)
  auth/passwords.ts             argon2 hash/verify
  auth/apiKeys.ts               API key generation + SHA-256 hashing
  auth/authMiddleware.ts        Bearer token -> req.userId, or 401
  auth/authRoutes.ts            POST /auth/register, POST /auth/login
  storage/types.ts              JobApplicationRepository interface — the seam between tools and storage
  storage/inMemoryRepository.ts In-memory implementation (used by tests)
  storage/postgresRepository.ts Postgres implementation, constructed per-request and scoped to one user
  tools/                        One file per tool + registerAllTools()
drizzle/                        Generated SQL migrations (npm run db:generate)
drizzle.config.ts               drizzle-kit config
scripts/verify-http.ts          Raw HTTP smoke test (registers a user, exercises every tool)
tests/unit/                     Schema, repository, and auth-logic unit tests
tests/integration/              Full tool lifecycle over an in-process MCP client
Dockerfile                      Multi-stage build: compile -> minimal non-root runtime, migrate-then-start entrypoint
docker-compose.yml              App + local Postgres — one command, no separate migration step
```

## Deploying publicly

This repo containerizes and documents deployment; it doesn't provision or manage any hosting for you.

1. **Get a Postgres database** you control — [Neon](https://neon.tech)'s free tier gives you a ready `postgresql://...?sslmode=require` string in under a minute.
2. **Build and run the image**, pointing at it:
   ```bash
   docker build -t jobtrack-mcp .
   docker run -d -p 3000:3000 -e DATABASE_URL="<your connection string>" -e NODE_ENV=production jobtrack-mcp
   ```
   (Migrations run automatically on container start — no separate step.)
3. **Put it on the public internet** — either a host that runs Docker behind a reverse proxy that terminates TLS (e.g. [Caddy](https://caddyserver.com/): `your-domain.com { reverse_proxy localhost:3000 }`, automatic HTTPS), or a container PaaS (Fly.io, Railway, Render) where you set `DATABASE_URL`/`NODE_ENV` as secrets and point their generated public URL at container port `3000`.

## Known dev-dependency advisories

`npm audit` flags moderate/high issues in transitive dev dependencies (esbuild's dev-server request handling via `vite`/`vitest`, and a Windows path-traversal issue in `@hono/node-server`, an optional dependency of the MCP SDK this project does not use). Neither is exploitable in this project's actual usage — noted here for visibility rather than treated as blocking.