Skip to main content
Glama
aliasdhacker

WorkPulse MCP Server

by aliasdhacker
README.md
# WorkPulse MCP Server

A [Model Context Protocol](https://modelcontextprotocol.io) server that lets Claude (Desktop, Code, or any MCP client) enter and query **expenses, invoices, and contracts** in a [WorkPulse](https://github.com/aliasdhacker) instance through its REST API.

- **14 tools** across expenses, invoices, contracts, lookups, batches, and OCR document processing
- **Batch rollback** — group bulk inserts and undo them in one call
- **Two transports** — `stdio` for local desktop use (default), or **Streamable HTTP protected by OAuth bearer tokens** for network deployment
- **Credentials stay server-side** — WorkPulse username/password live only in the server's environment; the model and MCP client never see them
- **Optional OCR pipeline** — turn bank statements, receipts and bills (PDF/images) into expenses via a DriftMoney DocTR + Ollama pipeline

```
Claude / MCP client  ⇄  WorkPulse MCP server  ⇄  WorkPulse REST API
                                  ⇣ (optional)
                            OCR pipeline
```

---

## Contents

- [Quick start](#quick-start)
- [Architecture](#architecture)
- [Credential model](#credential-model)
- [Transports](#transports)
  - [stdio (default)](#stdio-default)
  - [Streamable HTTP + OAuth](#streamable-http--oauth)
- [Client configuration](#client-configuration)
- [Tools](#tools)
- [Batch rollback](#batch-rollback)
- [OCR document processing](#ocr-document-processing)
- [Environment reference](#environment-reference)
- [Development](#development)
- [License](#license)

---

## Quick start

Requirements: Python 3.10+ and a running WorkPulse server.

```bash
git clone https://github.com/aliasdhacker/workpulse-mcp.git
cd workpulse-mcp
python -m venv .venv
# Windows: .venv\Scripts\activate    macOS/Linux: source .venv/bin/activate
pip install -e .
```

Set the WorkPulse credentials (these are read by the server process only):

```bash
export WORKPULSE_API_URL=http://localhost:8080
export WORKPULSE_USERNAME=<your-username>
export WORKPULSE_PASSWORD=<your-password>
```

Run it:

```bash
workpulse-mcp                # stdio, for Claude Desktop / Claude Code
# or
python -m workpulse_mcp.server
```

Then add it to your client — see [Client configuration](#client-configuration). Copy `.env.example` to `.env` if you prefer a file; it is git-ignored.

---

## Architecture

```
┌────────────────────────────────┐
│ Claude Desktop / Claude Code / │
│ any MCP client                 │
└──────────────┬─────────────────┘
               │  MCP JSON-RPC
               │  stdio  ─or─  Streamable HTTP (+ OAuth bearer token)
               ▼
┌────────────────────────────────────────────────────────────┐
│  WorkPulse MCP server  (Python, mcp SDK 2.x)               │
│                                                            │
│  http_auth.py   bearer-token verifier for HTTP transports  │
│  server.py      MCPServer + lifespan (logs in to WorkPulse)│
│  auth.py        WorkPulse JWT login / refresh / re-login   │
│  client.py      httpx client w/ bearer + retry/backoff     │
│  batch.py       batch tracker  → ~/.workpulse-mcp/batches.json
│  tools/         expenses · invoices · contracts · lookups  │
│                 batches · receipts                         │
└──────────────┬──────────────────────────┬──────────────────┘
               │ HTTPS + WorkPulse JWT     │ HTTP (optional)
               ▼                           ▼
┌──────────────────────────┐   ┌────────────────────────────┐
│  WorkPulse REST API      │   │  DriftMoney OCR pipeline   │
│  /api/expenses …         │   │  POST /parse               │
│  /api/auth/login|refresh │   │  (DocTR + Ollama)          │
└──────────────────────────┘   └────────────────────────────┘
```

Every tool call follows the same path: the tool calls `auth.ensure_authenticated()` (refreshing or re-logging-in as needed), then makes one or more REST calls through `client.py`, and — for `create_*` tools — records the new entity's ID in the active batch so it can be rolled back.

---

## Credential model

There are **two independent credential domains**, and they never cross.

| Domain | Who holds it | How it is used |
|---|---|---|
| **WorkPulse login** (`WORKPULSE_USERNAME` / `WORKPULSE_PASSWORD`) | The MCP server process only, via environment variables | Exchanged at startup for a WorkPulse JWT (`POST /api/auth/login`). The JWT is kept in memory, sent as `Authorization: Bearer` on every REST call, refreshed via `POST /api/auth/refresh` 30 s before expiry, and re-obtained with a full login if refresh fails. |
| **MCP endpoint access** (OAuth bearer token) | The MCP client (Claude) | Only for the HTTP transports. Presented to *this* server, which validates it and, if valid, serves the request using its own WorkPulse session. |

Consequences:

- The model never sees, and cannot request, the WorkPulse password or JWT. No tool exposes them; they are not in any tool output.
- Anyone who can reach the HTTP endpoint with a valid bearer token acts as the configured WorkPulse user. Scope your WorkPulse account accordingly.
- In stdio mode there is no network endpoint; the client launches the server as a child process and passes the env vars in its config (see below). Keep that config file private.

---

## Transports

Select with `MCP_TRANSPORT`. `stdio` is the default.

### stdio (default)

```bash
MCP_TRANSPORT=stdio workpulse-mcp
```

Standard for Claude Desktop and Claude Code. All logging goes to stderr; stdout is the JSON-RPC channel.

### Streamable HTTP + OAuth

```bash
MCP_TRANSPORT=streamable-http MCP_HOST=0.0.0.0 MCP_PORT=8888 workpulse-mcp
# endpoint: http://<host>:8888/mcp
```

The HTTP endpoint is **always** protected; the server refuses to start unless an auth mode is configured (or `MCP_AUTH_MODE=none` is set explicitly). Auth is implemented with the `mcp` SDK's built-in resource-server support: a `TokenVerifier` is passed to `MCPServer(token_verifier=…, auth=AuthSettings(…))`, and the SDK's `BearerAuthBackend` / `RequireAuthMiddleware` wrap the `/mcp` route and publish [RFC 9728 protected-resource metadata](https://datatracker.ietf.org/doc/html/rfc9728) at `/.well-known/oauth-protected-resource/mcp`, per the [MCP authorization spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization).

Unauthenticated requests get `401` with a `WWW-Authenticate: Bearer … resource_metadata="…"` header so spec-compliant clients can discover the authorization server; tokens missing a required scope get `403 insufficient_scope`.

Three verifier modes (`src/workpulse_mcp/http_auth.py`):

| Mode | `MCP_AUTH_MODE` | When to use | Required env |
|---|---|---|---|
| **Static token** | `static` (auto if `MCP_BEARER_TOKEN` is set) | Local development, a single trusted client | `MCP_BEARER_TOKEN` (≥16 chars) |
| **JWT (JWKS)** | `jwt` (auto if `MCP_OAUTH_ISSUER` is set) | Production with an OAuth 2.1 / OIDC authorization server issuing signed JWT access tokens | `MCP_OAUTH_ISSUER`, `MCP_RESOURCE_URL` (and optionally `MCP_OAUTH_JWKS_URL`, `MCP_OAUTH_AUDIENCE`, `MCP_OAUTH_REQUIRED_SCOPES`) |
| **Introspection** | `introspection` (auto if `MCP_OAUTH_INTROSPECTION_URL` is set) | Production with opaque tokens (RFC 7662) | `MCP_OAUTH_ISSUER`, `MCP_OAUTH_INTROSPECTION_URL`, `MCP_RESOURCE_URL`, optionally `MCP_OAUTH_CLIENT_ID` / `MCP_OAUTH_CLIENT_SECRET` |
| **None** | `none` (never auto) | Isolated lab networks only | — |

**Development (static token):**

```bash
export MCP_TRANSPORT=streamable-http
export MCP_BEARER_TOKEN="$(python -c 'import secrets;print(secrets.token_urlsafe(32))')"
export WORKPULSE_USERNAME=<your-username> WORKPULSE_PASSWORD=<your-password>
workpulse-mcp
```

Clients send `Authorization: Bearer <that token>` (see `examples/mcp.http.json.example`).

**Production (JWT):**

```bash
export MCP_TRANSPORT=streamable-http
export MCP_HOST=0.0.0.0 MCP_PORT=8888
export MCP_RESOURCE_URL=https://mcp.example.com/mcp      # public URL of this endpoint = expected `aud`
export MCP_OAUTH_ISSUER=https://auth.example.com          # your authorization server
export MCP_OAUTH_REQUIRED_SCOPES=workpulse:write          # optional
workpulse-mcp
```

The verifier discovers `jwks_uri` from the issuer's `/.well-known/oauth-authorization-server` (or `/.well-known/openid-configuration`), caches keys, and validates signature, `iss`, `aud`, and `exp` with PyJWT. Put TLS termination (nginx, Caddy, a cloud load balancer) in front of it; the server itself speaks plain HTTP.

Register `https://mcp.example.com/mcp` as a resource / audience in your authorization server and let clients obtain tokens for it through the normal OAuth 2.1 + PKCE flow. Because this server publishes protected-resource metadata, MCP clients that implement the authorization spec (Claude's remote-MCP connectors, MCP Inspector, etc.) find your authorization server automatically from the `401`.

`sse` is also accepted for legacy clients and is protected identically.

---

## Client configuration

### Claude Code (`.mcp.json` in your project, or `claude mcp add`)

```json
{
  "mcpServers": {
    "workpulse": {
      "type": "stdio",
      "command": "workpulse-mcp",
      "env": {
        "WORKPULSE_API_URL": "http://localhost:8080",
        "WORKPULSE_USERNAME": "<your-username>",
        "WORKPULSE_PASSWORD": "<your-password>",
        "OCR_PIPELINE_URL": "http://localhost:8000"
      }
    }
  }
}
```

Remote server over Streamable HTTP:

```json
{
  "mcpServers": {
    "workpulse": {
      "type": "http",
      "url": "http://localhost:8888/mcp",
      "headers": { "Authorization": "Bearer <your-mcp-bearer-token>" }
    }
  }
}
```

### Claude Desktop (`claude_desktop_config.json`)

```json
{
  "mcpServers": {
    "workpulse": {
      "command": "python",
      "args": ["-m", "workpulse_mcp.server"],
      "env": {
        "WORKPULSE_API_URL": "http://localhost:8080",
        "WORKPULSE_USERNAME": "<your-username>",
        "WORKPULSE_PASSWORD": "<your-password>",
        "OCR_PIPELINE_URL": "http://localhost:8000"
      }
    }
  }
}
```

If `python` on your PATH is not the interpreter where you installed the package, use the venv's interpreter (`.../.venv/bin/python` or `...\.venv\Scripts\python.exe`) as `command`.

Ready-to-copy versions live in [`examples/`](examples/).

---

## Tools

All tools return JSON text. IDs referenced below come from the lookup tools.

### Expenses

#### `create_expense`

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `date` | string | yes | YYYY-MM-DD |
| `amount` | float | yes | Positive dollar amount |
| `category` | string | yes | SUPPLIES, SOFTWARE, TRAVEL, EQUIPMENT, SUBCONTRACTOR, OTHER |
| `vendor` | string | no | Merchant name |
| `description` | string | no | What was purchased |
| `receipt_reference` | string | no | Receipt number or file reference |
| `project_id` | int | no | Associate with a project |
| `tax_category` | string | no | DEDUCTIBLE (default), PARTIALLY_DEDUCTIBLE, NON_DEDUCTIBLE |
| `batch_id` | string | no | Batch to track this in (uses active batch if omitted) |

> Create an expense for $127.50 at Office Depot on 2026-02-20 for supplies, tax deductible

#### `list_expenses`

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `start` | string | no | Start date YYYY-MM-DD |
| `end` | string | no | End date YYYY-MM-DD |
| `category` | string | no | Filter by category |

> Show me all travel expenses from January 2026

### Invoices

#### `create_invoice`

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `client_id` | int | yes | Client ID (use `list_clients` to find) |
| `invoice_number` | string | yes | e.g. "INV-2026-003" |
| `invoice_date` | string | yes | YYYY-MM-DD |
| `subtotal` | float | no | Subtotal before adjustments |
| `total` | float | no | Total after adjustments/discounts |
| `status` | string | no | DRAFT (default), SENT, PAID |
| `period_start` | string | no | Billing period start YYYY-MM-DD |
| `period_end` | string | no | Billing period end YYYY-MM-DD |
| `adjustment` | float | no | Adjustment amount (can be negative) |
| `adjustment_note` | string | no | Reason for adjustment |
| `discount_amount` | float | no | Fixed discount |
| `discount_percent` | float | no | Percentage discount (0-100) |
| `discount_reason` | string | no | Reason for discount |
| `due_date` | string | no | Payment due date YYYY-MM-DD |
| `notes` | string | no | Additional notes |
| `batch_id` | string | no | Batch for rollback tracking |

> Create a draft invoice INV-2026-003 for client Acme Corp dated today, subtotal $3200, total $3200, for the period Feb 1-28

#### `generate_invoice`

Generates an invoice from billable time entries; the API pulls matching entries and creates line items.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `client_id` | int | yes | Client ID |
| `period_start` | string | yes | YYYY-MM-DD |
| `period_end` | string | yes | YYYY-MM-DD |
| `invoice_number` | string | no | Override invoice number |
| `invoice_date` | string | no | Override invoice date |
| `adjustment` | float | no | Adjustment amount |
| `adjustment_note` | string | no | Reason for adjustment |
| `notes` | string | no | Additional notes |
| `batch_id` | string | no | Batch for rollback tracking |

> Generate an invoice for Acme Corp covering January 2026

#### `list_invoices`

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `client_id` | int | no | Filter by client |
| `status` | string | no | DRAFT, SENT, PAID |

> Show me all unpaid invoices for client 3

### Contracts

#### `create_contract`

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `client_id` | int | yes | Client ID |
| `title` | string | yes | Contract title |
| `contract_type` | string | no | HOURLY (default), FIXED_PRICE, RETAINER |
| `status` | string | no | DRAFT (default), SENT, ACTIVE, COMPLETED, EXPIRED, TERMINATED |
| `description` | string | no | Contract description |
| `terms` | string | no | Payment terms |
| `scope` | string | no | Scope of work |
| `hourly_rate` | float | no | Rate for HOURLY contracts |
| `estimated_hours` | float | no | Estimated hours |
| `estimated_amount` | float | no | Estimated total for FIXED_PRICE |
| `start_date` | string | no | YYYY-MM-DD |
| `end_date` | string | no | YYYY-MM-DD |
| `batch_id` | string | no | Batch for rollback tracking |

> Create a fixed price contract for Acme Corp titled "Website Redesign" for $15,000, starting March 1 through June 30

#### `list_contracts`

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `client_id` | int | no | Filter by client |
| `status` | string | no | DRAFT, SENT, ACTIVE, COMPLETED, EXPIRED, TERMINATED |

> Show me all active contracts

### Lookups

#### `list_clients`

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `active` | bool | no | Only active clients (default: true) |

> List all my clients

#### `list_projects`

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `client_id` | int | no | Filter by client |
| `active` | bool | no | Only active projects (default: true) |

> What projects does Acme Corp have?

### Batches

#### `start_batch`

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `name` | string | yes | Descriptive name for the batch |

> Start a batch called "February 2026 expenses"

#### `list_batches`

No parameters. Shows all batches with items, counts, active flag, and rollback status.

#### `rollback_batch`

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `batch_id` | string | yes | Batch ID from `start_batch` |

> Roll back the February expenses batch

### Receipts / OCR

#### `process_document`

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `file_path` | string | yes | Path to the document (PDF, PNG, JPG, JPEG, GIF, BMP, TIFF, WEBP) |
| `batch_id` | string | no | Batch for rollback tracking |
| `default_tax_category` | string | no | DEDUCTIBLE (default), PARTIALLY_DEDUCTIBLE, NON_DEDUCTIBLE |
| `dry_run` | bool | no | Extract only; do not create expenses |

> Dry run on C:\receipts\receipt.png so I can review before creating

#### `process_documents_folder`

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `folder_path` | string | yes | Folder containing documents |
| `batch_name` | string | no | Batch name (defaults to folder name) |
| `default_tax_category` | string | no | DEDUCTIBLE (default) |
| `dry_run` | bool | no | Extract only; do not create expenses |

Automatically starts a batch (unless `dry_run`).

> Process all the documents in C:\statements\2026-Q1 and call the batch "Q1 2026 statements"

---

## Batch rollback

Bulk data entry with an LLM needs an undo button. Batches provide it:

1. `start_batch("week of Feb 24")` — becomes the *active* batch.
2. Every subsequent `create_expense` / `create_invoice` / `generate_invoice` / `create_contract` (and OCR-created expense) records `{type, id, endpoint}` in the active batch, unless an explicit `batch_id` is given.
3. `rollback_batch(batch_id)` issues `DELETE` on each recorded endpoint in **reverse creation order**, reports per-item success/failure, and marks the batch rolled back (it cannot be rolled back twice).

State is persisted to `WORKPULSE_MCP_DATA_DIR/batches.json` (default `~/.workpulse-mcp/`) so batches survive server restarts. Rollback deletes data in WorkPulse — it is not reversible.

Example session:

> Start a batch called "week of Feb 24". Then create these expenses:
> - Feb 24: $45.99 at Staples for supplies
> - Feb 25: $89.00 for Adobe Creative Cloud, software
> - Feb 26: $234.00 at Delta Airlines, travel
>
> …
>
> That last one was personal — roll back the "week of Feb 24" batch

---

## OCR document processing

Optional. The `process_document*` tools send files to a DriftMoney OCR pipeline (`POST /parse`, DocTR + Ollama) and map its output to WorkPulse expenses:

```
PDF / image → DocTR OCR → Ollama LLM → DriftMoney DSL → WorkPulse expenses
```

Set `OCR_PIPELINE_URL` (default `http://localhost:8000`). Income and credit transactions are filtered out; debt information from card/loan statements is returned as informational data but not inserted.

| DriftMoney category | WorkPulse category |
|---|---|
| Food, Groceries, Dining, Shopping, Retail, Office | SUPPLIES |
| Software, Subscription, SaaS, Digital | SOFTWARE |
| Travel, Transportation, Gas, Fuel, Uber, Lyft, Airline, Hotel | TRAVEL |
| Equipment, Hardware, Electronics | EQUIPMENT |
| Contractor, Subcontractor, Freelance | SUBCONTRACTOR |
| Utilities, Insurance, Medical, Entertainment, and anything else | OTHER |

Use `dry_run=true` first and review before creating; folder processing always wraps creates in a batch.

---

## Environment reference

| Variable | Default | Purpose |
|---|---|---|
| `WORKPULSE_API_URL` | `http://localhost:8080` | WorkPulse REST base URL |
| `WORKPULSE_USERNAME` | — | **Required.** WorkPulse login |
| `WORKPULSE_PASSWORD` | — | **Required.** WorkPulse password |
| `WORKPULSE_MCP_DATA_DIR` | `~/.workpulse-mcp` | Batch state directory |
| `OCR_PIPELINE_URL` | `http://localhost:8000` | DriftMoney OCR pipeline |
| `MCP_TRANSPORT` | `stdio` | `stdio` · `streamable-http` · `sse` |
| `MCP_HOST` / `MCP_PORT` | `127.0.0.1` / `8888` | Bind address for HTTP transports |
| `MCP_PATH` | `/mcp` | Streamable HTTP endpoint path |
| `MCP_STATELESS` | `false` | Stateless Streamable HTTP mode |
| `MCP_AUTH_MODE` | auto | `static` · `jwt` · `introspection` · `none` |
| `MCP_BEARER_TOKEN` | — | Pre-shared token (static mode) |
| `MCP_RESOURCE_URL` | `http://<host>:<port>/mcp` | Public URL of this endpoint; OAuth resource identifier |
| `MCP_OAUTH_ISSUER` | — | Authorization server issuer URL |
| `MCP_OAUTH_AUDIENCE` | `MCP_RESOURCE_URL` | Expected `aud` claim |
| `MCP_OAUTH_JWKS_URL` | discovered | Explicit JWKS URL |
| `MCP_OAUTH_ALGORITHMS` | RS*/ES*/PS256 | Allowed JWT algorithms |
| `MCP_OAUTH_INTROSPECTION_URL` | — | RFC 7662 endpoint |
| `MCP_OAUTH_CLIENT_ID` / `_SECRET` | — | Credentials for the introspection call |
| `MCP_OAUTH_REQUIRED_SCOPES` | — | Scopes every token must carry |

See [`.env.example`](.env.example) for an annotated template.

---

## Development

```bash
pip install -e ".[dev]"
pytest                                  # 45 tests: AuthManager, verifiers, SDK middleware, end-to-end
./run-inspector.sh                      # MCP Inspector against stdio (reads .env)
mcp dev src/workpulse_mcp/server.py     # alternative Inspector launcher
```

The test suite needs no WorkPulse instance: REST calls are mocked with `respx`, and the end-to-end test drives the real server through the SDK's Streamable HTTP app in-process.

Layout:

```
src/workpulse_mcp/
  server.py        MCPServer construction, transport selection, lifespan
  http_auth.py     TokenVerifier implementations + build_auth_from_env()
  auth.py          WorkPulse JWT session (login / refresh / re-login)
  client.py        httpx wrapper with bearer header, retries, error mapping
  batch.py         BatchTracker (persistent, rollback in reverse order)
  ocr_pipeline.py  OCR client + DriftMoney → WorkPulse mapping
  tools/           one module per tool group
tests/             pytest (+ pytest-asyncio, respx)
examples/          client config templates
```

Requires `mcp>=2.0` (the 2.x SDK renamed `FastMCP` to `MCPServer` and moved host/port to `run()`).

---

## License

MIT — see [LICENSE](LICENSE). Copyright (c) 2026 Andrew Carr.