Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: CI

on:
push:
branches: [main, dev]
pull_request:
branches: [main, dev]

jobs:
api:
name: Go API
runs-on: ubuntu-latest
defaults:
run:
working-directory: api
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version-file: api/go.mod

- name: Build
run: go build ./...

- name: Vet
run: go vet ./...

- name: Test
run: go test ./...

mcp:
name: MCP server (Python)
runs-on: ubuntu-latest
defaults:
run:
working-directory: mcp
steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v3
with:
enable-cache: true

- name: Install dependencies
run: uv sync --all-extras --dev

- name: Lint
run: uv run ruff check .

- name: Type check
run: uv run mypy .

- name: Test
run: uv run pytest
19 changes: 19 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -216,3 +216,22 @@ __marimo__/

# Streamlit
.streamlit/secrets.toml

# macOS
.DS_Store

# Go
*.exe
*.exe~
*.dll
*.test
*.out
/bin/
/api/bin/
vendor/

# Claude
.claude/
.claude.json
CLAUDE.local.md
.mcp.json
92 changes: 92 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# SubTrack

Track recurring subscriptions from an LLM agent — a thin MCP server over a Go backend.

![SubTrack demo](docs/demo.gif)

SubTrack is a small monorepo: a Go HTTP backend (`api/`) that owns all subscription data and
business logic, and a Python [FastMCP](https://gofastmcp.com/) server (`mcp/`) that exposes a
curated set of tools to MCP clients such as Claude Desktop. The MCP layer holds no business logic
of its own — it only speaks REST to the backend, so it's language-agnostic and the same backend
could sit behind any other client.

## Architecture

```mermaid
flowchart TD
A["Claude Desktop / MCP client"] -->|"MCP · stdio"| B["mcp<br/>FastMCP · 7 curated tools"]
B -->|"REST · X-API-Key"| C["api<br/>Go · net/http"]
C -->|pgxpool| D[("PostgreSQL 16")]
```

## Tools

| Tool | Kind | Backend call |
|---|---|---|
| `list_subscriptions` | read | `GET /v1/subscriptions` |
| `get_subscription` | read | `GET /v1/subscriptions/{id}` |
| `spending_summary` | read | `GET /v1/subscriptions/summary` |
| `upcoming_charges` | read | `GET /v1/subscriptions/summary` (filtered client-side) |
| `add_subscription` | write | `POST /v1/subscriptions` |
| `update_subscription` | write | `PATCH /v1/subscriptions/{id}` |
| `cancel_subscription` | write | `POST /v1/subscriptions/{id}/cancel` |

The surface is curated, not auto-generated from the REST API: tools are task-shaped
(`upcoming_charges`, `spending_summary`) rather than a 1:1 endpoint mirror, which keeps the
agent's choices small and unambiguous.

## Quickstart

Requires [Docker](https://docs.docker.com/), Go (see `api/go.mod`), and
[uv](https://docs.astral.sh/uv/).

```sh
# 1. Start the backend: Postgres 16 (5432) and the API (8080).
cd api
cp .env.example .env # optional — compose ships working dev defaults
docker compose up -d
make migrate-up

# 2. Sanity check.
curl http://localhost:8080/healthz
curl -H "X-API-Key: dev-local-key" http://localhost:8080/v1/subscriptions

# 3. Run the MCP server.
cd ../mcp
uv sync
export SUBTRACK_API_URL=http://localhost:8080
export SUBTRACK_API_KEY=dev-local-key # matches api/.env.example
uv run subtrack-mcp
```

`SUBTRACK_API_URL` and `SUBTRACK_API_KEY` are mandatory — the MCP server fails fast at startup if
either is missing. Point your MCP client at `uv run subtrack-mcp` with the same two variables set,
and it connects over stdio.

## Key design decisions

- **Logic in the backend, not the MCP.** The MCP server is stateless and thin; all validation,
spending-total and next-charge calculations live in Go.
- **Curated tool surface.** Seven task-shaped tools instead of an auto-exposed endpoint mirror.
- **Money as integer cents.** No floats anywhere in the wire shapes — enforced by a
`CHECK (cost_cents > 0)` constraint in the schema.
- **Schema only in migrations.** The database is defined in `api/migrations/`, never created from
application code.
- **Constant-time API-key auth.** Every `/v1/*` route requires a shared secret via `X-API-Key`,
compared with `crypto/subtle`.

## Repo structure

```
.
├── api/ # Go backend: handler -> service -> repository -> model, Postgres, migrations
├── mcp/ # Python FastMCP server: thin REST client, curated tools
└── docs/ # demo.gif for this README
```

- [`api/README.md`](api/README.md) — backend layering, data model, endpoints, auth, migrations.
- [`mcp/README.md`](mcp/README.md) — MCP tools, auth against the backend, dev gates.

## License

MIT.
Binary file added api/.DS_Store
Binary file not shown.
10 changes: 10 additions & 0 deletions api/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.git
.gitignore
*.md
.env
.env.*
!.env.example
docker-compose.yml
Dockerfile
.dockerignore
progress
21 changes: 21 additions & 0 deletions api/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Copy this file to .env and adjust values for your local setup.
# These are DEV placeholder values only — never commit real credentials.

# Port the HTTP server listens on.
PORT=8080

# Shared secret callers must present via the X-API-Key header on every
# /v1/* route (see internal/handler.RequireAPIKey). Required — the server
# fails to start without it. This is a DEV placeholder only.
API_KEY=dev-local-key

# Either set DATABASE_URL directly...
# DATABASE_URL=postgres://subtrack:devpassword@localhost:5432/subtrack?sslmode=disable

# ...or provide the discrete POSTGRES_* parts (used to build the DSN
# automatically when DATABASE_URL is unset).
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_USER=subtrack
POSTGRES_PASSWORD=devpassword
POSTGRES_DB=subtrack
20 changes: 20 additions & 0 deletions api/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Stage 1: build a static binary
FROM golang:1.26 AS build

WORKDIR /src

COPY go.mod go.sum ./
RUN go mod download

COPY . .

RUN CGO_ENABLED=0 GOOS=linux go build -o /out/api ./cmd/api

# Stage 2: minimal runtime image
FROM gcr.io/distroless/static-debian12:nonroot

COPY --from=build /out/api /api

EXPOSE 8080

ENTRYPOINT ["/api"]
18 changes: 18 additions & 0 deletions api/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
.PHONY: migrate-up migrate-down demo

# Apply all pending migrations against the configured database (DATABASE_URL
# or POSTGRES_* environment variables — see internal/config).
migrate-up:
go run ./cmd/migrate up

# Fully revert all applied migrations against the configured database.
migrate-down:
go run ./cmd/migrate down

# Seed 3 sample subscriptions and print the spending summary.
# Prerequisite: the stack must already be up and migrated:
# docker compose up -d && make migrate-up
# Respects BASE_URL (default http://localhost:8080) and API_KEY
# (default dev-local-key).
demo:
./scripts/demo.sh
83 changes: 83 additions & 0 deletions api/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# api

The Go backend for SubTrack — system of record for recurring subscriptions and spending totals.
See the [root README](../README.md) for the architecture and full quickstart.

## Layering

Strict one-direction dependency chain:

- **handler** (`internal/handler`) — decodes HTTP requests, maps domain errors to status codes,
encodes JSON wire shapes. No business logic.
- **service** (`internal/subscription`) — the domain: validation, create/update/cancel/list,
and the spending-total and next-charge calculations. No HTTP or SQL.
- **repository** (`internal/postgres`) — persistence via `pgxpool`. No domain rules.
- **model** (`internal/subscription`) — the `Subscription` domain type and its input shapes.

Entry points are `cmd/api` (the server) and `cmd/migrate` (schema migrations).

## Data model

One table, `subscriptions`, defined in `migrations/`:

| Column | Type | Notes |
|---|---|---|
| `id` | `uuid` | server-generated (`gen_random_uuid()`) |
| `name` | `text` | non-empty |
| `cost_cents` | `int` | `> 0` — money is integer cents, never floats |
| `currency` | `char(3)` | e.g. `EUR` |
| `cycle` | `text` | `monthly` or `yearly` |
| `billing_day` | `int` | `1`–`28` |
| `start_date` | `date` | |
| `active` | `bool` | defaults `true`; cleared by cancel |
| `created_at` / `updated_at` | `timestamptz` | server-managed |

All monetary fields on the wire are integer cents.

## Endpoints

| Method | Path | Auth | Returns |
|---|---|---|---|
| GET | `/healthz` | none | `{"status":"ok"}` when the DB is reachable |
| POST | `/v1/subscriptions` | `X-API-Key` | 201 + created subscription |
| GET | `/v1/subscriptions?active=` | `X-API-Key` | `{"subscriptions": [...]}` (all, or active only) |
| GET | `/v1/subscriptions/summary` | `X-API-Key` | monthly/annual totals + per-subscription breakdown |
| GET | `/v1/subscriptions/{id}` | `X-API-Key` | the subscription, or 404 |
| PATCH | `/v1/subscriptions/{id}` | `X-API-Key` | updated subscription (partial update) |
| POST | `/v1/subscriptions/{id}/cancel` | `X-API-Key` | subscription with `active=false` (idempotent) |

`/healthz` is registered unwrapped so monitoring can reach it without a key. Every `/v1/*` route is
wrapped by the API-key middleware.

## Auth

Every `/v1/*` route requires a shared secret in the `X-API-Key` header, compared in constant time
(`crypto/subtle`) so response timing cannot be used to probe the key. A missing or wrong key both
return a generic 401. This is a deliberately minimal scheme for a single trusted client; OAuth is
the production follow-up.

## Quickstart

```sh
cp .env.example .env # optional — compose ships working dev defaults
docker compose up -d # Postgres 16 (5432) + API (8080)
make migrate-up # apply migrations
curl http://localhost:8080/healthz
```

`make migrate-down` reverts all migrations. `make demo` seeds three subscriptions and prints the
spending summary (requires the stack up and migrated first).

Key env vars: `PORT` (default `8080`), `API_KEY` (required — the server won't start without it;
dev default `dev-local-key`), and either `DATABASE_URL` or the discrete `POSTGRES_*` parts.

## Migrations

Schema lives only in `migrations/`, applied with [golang-migrate](https://github.com/golang-migrate/migrate)
via `cmd/migrate`, which reuses the server's config loader so both target the same database. No
schema is created from application code.

## See also

- [Root README](../README.md) — architecture, MCP tools, full quickstart.
- [`mcp/README.md`](../mcp/README.md) — the FastMCP server exposing this backend to LLM agents.
Binary file added api/cmd/.DS_Store
Binary file not shown.
Loading
Loading